diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 312a7d5..81531ba 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,50 +2,265 @@ name: iOS 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 + export_diagnostic: + 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: - group: ios-${{ github.ref }} + 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: runs-on: macos-15 - timeout-minutes: 45 + 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' }} + 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.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@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, verified upstream and actual focused47 job with: submodules: recursive + persist-credentials: false + - name: Validate diagnostic selection + run: | + 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 + - 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: SourcePackages + 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- + - 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@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', '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', '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 - - name: Select available iPhone simulator + 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 -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 + 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', []))} + diagnostic = os.environ['ORIENTATION_DIAGNOSTIC'] == 'true' + export_diagnostic = os.environ['EXPORT_DIAGNOSTIC'] == 'true' + 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': '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, + 'orientations': orientations, 'validations': validations} + 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 + timeout-minutes: 10 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" - - name: Compile and test on simulator + 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" "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 - 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 + UI_SELECTION=SpaceWorksUITests + 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" "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: | 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 -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 + python3 scripts/dependency_lock.py check + - 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 --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: 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@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: ios-validation + 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 TestAttachments build.log + release-build.log + validation-device.json + validation-bundle.json + validation-debug-bundle.json + dependency-lock-validation.json + 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 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@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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + DerivedData/Build + DerivedData/ModuleCache.noindex + DerivedData/SDKStatCaches.noindex + key: ${{ steps.compiled-cache.outputs.cache-primary-key }} diff --git a/.gitignore b/.gitignore index a00de35..9e83129 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,18 @@ google-services.json *.mobileprovision *.xcresult/ __pycache__/ +/validation-device.json +/TestAttachments/ +/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 31d42f8..57a6fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,30 @@ 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 subsequently updated the delivery workflow: + +- Start ongoing work from `dev`, commit there, and push to `origin/dev`. +- 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) + +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. Consolidate the batch +on `dev`, complete the required repository validation and actual visual review, then merge the tested +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` +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. + +Mobile-specific onboarding/auth UX and motion belong in `docs/mobile-auth-ux-spec.md` and +`tokens/mobile-motion.json` in this repository. Do not push mobile implementation/spec additions +to the Design repository. The Design submodule supplies the existing brand masters and colors. 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/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/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..cdb9bf7 --- /dev/null +++ b/Fixtures/Member/bookings.json @@ -0,0 +1,18 @@ +{ + "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, + "confirmation_available": false + } + ] +} diff --git a/Fixtures/Member/bootstrap.json b/Fixtures/Member/bootstrap.json new file mode 100644 index 0000000..4f1f0f3 --- /dev/null +++ b/Fixtures/Member/bootstrap.json @@ -0,0 +1,53 @@ +{ + "makerspace": { + "id": 7, + "name": "Fab Lab Kochi", + "slug": "fab-lab-kochi", + "public_code": "demo-fab", + "location": "Kochi", + "timezone": "Asia/Kolkata", + "opening_hours": {}, + "map_url": "", + "logo_url": null, + "cover_image_url": null, + "public_stats_enabled": true, + "public_site_enabled": true, + "public_inventory_enabled": true, + "public_landing_page": "auto", + "membership_policy": "invite_only" + }, + "frontend": { + "type": "native-preview", + "hostname": "demo.invalid", + "allowed_origins": [] + }, + "modules": [ + "membership", + "member_accounts", + "notifications", + "hardware_requests", + "events", + "bookable_spaces" + ], + "edition": "demo", + "features": [ + "membership.self_service", + "membership.self_export", + "notifications.member_preferences", + "notifications.member_inbox", + "notifications.digests" + ], + "workflows": [], + "theme": {}, + "branding": {}, + "email_enabled": true, + "public_api": { + "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/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..d075de5 --- /dev/null +++ b/Fixtures/Member/events.json @@ -0,0 +1,36 @@ +[ + { + "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, + "audience_label": null, + "participating_organizations": [], + "registration_count": null, + "show_registration_count": false, + "website": 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/Fixtures/Staff/booking.json b/Fixtures/Staff/booking.json new file mode 100644 index 0000000..a987cad --- /dev/null +++ b/Fixtures/Staff/booking.json @@ -0,0 +1,19 @@ +{ + "arrived_at": null, + "cancelled_at": null, + "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", + "restore_until": null, + "space_id": 901, + "starts_at": "2026-10-16T09:00:00Z", + "status": "pending", + "status_before_cancel": "" +} 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..cf5fe96 --- /dev/null +++ b/Fixtures/Staff/event.json @@ -0,0 +1,52 @@ +{ + "audience_label": null, + "badge_sensitive_fields_allowed": false, + "broadcasts_enabled": false, + "can_manage_staff": false, + "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, + "organization_admission_blocked": false, + "organizers": [], + "participating_organizations": [], + "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, + "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, + "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..adad19a --- /dev/null +++ b/Fixtures/Staff/registration.json @@ -0,0 +1,18 @@ +{ + "cancelled_at": null, + "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", + "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/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..74cf9a1 --- /dev/null +++ b/Fixtures/Staff/space.json @@ -0,0 +1,44 @@ +{ + "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, + "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", + "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, + "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/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/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/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/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/MemberActionsOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift new file mode 100644 index 0000000..b750d8e --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/MemberActionsOperations.swift @@ -0,0 +1,61 @@ +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 + } + public func submitMemberRequest(slug: String, items: [Components.Schemas.RequestItemInput], purpose: String, idempotencyKey: String) async throws -> Components.Schemas.RequestSubmitResponse { + try await client.api_v1_public_requests_create(.init(path: .init(makerspace_slug: slug), + headers: .init(Idempotency_hyphen_Key: idempotencyKey), + body: .json(.init(requested_for: purpose, items: items)))).created.body.json + } + public func renewMemberLoan(id: Int, requestID: Int) async throws -> Components.Schemas.LoanRenewal { + try await client.api_v1_member_makerspaces_loans_renew_create(.init(path: .init(id: requestID, makerspace_id: id))).ok.body.json + } + public func bookingAvailability(slug: String, token: String, start: Date, end: Date) async throws -> Components.Schemas.PublicSpaceAvailability { + 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 -> 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 { + try await client.api_v1_public_my_bookings_cancel_create(.init(path: .init(makerspace_slug: slug, public_token: token))).ok.body.json + } + public func registerMemberEvent(slug: String, token: String, input: Components.Schemas.PublicEventRegistrationInput) async throws -> Components.Schemas.PublicEventRegistrationResponse { + try await client.api_v1_public_events_register_create(.init(path: .init(makerspace_slug: slug, public_token: token), body: .json(input))).created.body.json + } + public func cancelMemberEvent(id: Int, registration: Int) async throws -> Components.Schemas.MemberRegistrationCancelResponse { + try await client.api_v1_member_makerspaces_event_registrations_cancel_create(.init(path: .init(id: registration, makerspace_id: id))).ok.body.json + } + public func memberEventQR(id: Int, registration: Int) async throws -> Data { + let body = try await client.api_v1_member_makerspaces_event_registrations_qr_retrieve(.init(path: .init(id: registration, makerspace_id: id))).ok.body.image_svg_plus_xml + return try await Data(collecting: body, upTo: 512 * 1024) + } + public func memberProfile(id: Int) async throws -> Components.Schemas.AccountProfile { + try await client.api_v1_member_makerspaces_account_profile_retrieve(.init(path: .init(makerspace_id: id))).ok.body.json + } + public func updateMemberProfile(id: Int, input: Components.Schemas.PatchedAccountProfile) async throws -> Components.Schemas.AccountProfile { + try await client.api_v1_member_makerspaces_account_profile_partial_update(.init(path: .init(makerspace_id: id), body: .json(input))).ok.body.json + } + public func communicationPreferences(id: Int) async throws -> Components.Schemas.PreferenceResponse { + try await client.api_v1_notifications_preferences_retrieve(.init(path: .init(makerspace_id: id))).ok.body.json + } + public func updateCommunicationPreference(id: Int, input: Components.Schemas.PatchedPreferenceWrite) async throws -> Components.Schemas.PreferenceResponse { + try await client.api_v1_notifications_preferences_partial_update(.init(path: .init(makerspace_id: id), body: .json(input))).ok.body.json + } +} 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 701e94f..5219b12 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,53 +48,105 @@ 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 } } + 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) } - 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 } 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 + } + 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 + } +} + +/// 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 { +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 } } -/// One actor owns the pair and the in-flight task. Late 401s from the old generation reuse the new pair. +/// Credentials and installation epoch are captured atomically for each request. +public struct CredentialSnapshot: Sendable { + public let pair: TokenPair? + fileprivate let epoch: Int +} + +/// Late 401s may reuse a rotated pair only within the same installed session. 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 recover(rejectedAccess: String) async throws -> TokenPair { - guard let current = pair else { throw APIError(status: 401, code: "signed_out", detail: "Sign in to continue.") } + 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() + flight = nil + pair = nil + try persist(value) + pair = value + } + 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: APILocalCopy.text("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 } } @@ -110,13 +162,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.") + throw APIError(status: 403, code: "inactive_membership", detail: APILocalCopy.text("Select an active makerspace membership.")) } + activeDirectMemberships = activeMemberships selected = id } } @@ -130,13 +185,24 @@ private func checked(_ result: (HTTPResponse, HTTPBody?)) async throws -> (HTTPR let object = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } let code = envelope?.code ?? object?["code"] as? String let detail = envelope?.detail ?? object?["detail"] as? String - let fields = object?.keys.sorted().filter { !["detail", "code"].contains($0) }.compactMap { key -> String? in - guard let messages = object?[key] as? [String], !messages.isEmpty else { return nil } - return "\(key): \(messages.joined(separator: " "))" - }.joined(separator: "\n") + var fieldMessages: [String] = [] + func collect(_ value: Any, path: String, depth: Int) { + guard depth <= 8, fieldMessages.count < 50 else { return } + if let text = value as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + fieldMessages.append("\(path): \(text.prefix(1000))") + } else if let strings = value as? [String], !strings.isEmpty { + fieldMessages.append("\(path): \(strings.joined(separator: " ").prefix(1000))") + } else if let dictionary = value as? [String: Any] { + for key in dictionary.keys.sorted() { collect(dictionary[key]!, path: path.isEmpty ? key : path + "." + key, depth: depth + 1) } + } else if let array = value as? [Any] { + for (index, item) in array.prefix(50).enumerated() { collect(item, path: path + "." + String(index), depth: depth + 1) } + } + } + for key in object?.keys.sorted() ?? [] where !["detail", "code"].contains(key) { collect(object![key]!, path: key, depth: 0) } + 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") @@ -145,20 +211,21 @@ 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." - 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 } - else if response.status.code == 400, let fields, !fields.isEmpty { message = fields } + else if response.status.code == 400, !fields.isEmpty { message = fields } else { message = fallback } throw APIError(status: response.status.code, code: code ?? "http_\(response.status.code)", detail: message, retryAfter: retry) } @@ -177,6 +244,13 @@ private struct ErrorMiddleware: ClientMiddleware { } } +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 { public let refresh: RefreshCoordinator public let makerspace: MakerspaceContext @@ -185,12 +259,28 @@ public struct SessionMiddleware: ClientMiddleware { next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { var request = request for name in ["Cookie", "Origin", "Referer"] { request.headerFields[HTTPField.Name(name)!] = nil } - let pair = await refresh.current() + let credentials = await refresh.snapshot() + if let expected = DeviceRequestScope.credentials, credentials.epoch != expected.epoch { throw CancellationError() } + let pair = credentials.pair 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() } + 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() } + } + 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, let pair { - let updated = try await refresh.recover(rejectedAccess: pair.access) + 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)) } @@ -220,16 +310,19 @@ public enum DeviceSession { } public final class SpaceWorksService: Sendable { - private let client: Client + let client: Client + 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 +335,108 @@ 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, 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: APILocalCopy.text("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.$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) + } + } + } + + /// 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) { + // 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) + } + } + } + } + + 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: 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 { + 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 + } + 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/PaymentOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift new file mode 100644 index 0000000..0c77e61 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PaymentOperations.swift @@ -0,0 +1,74 @@ +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 + } +} + +/// 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 { + 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: APILocalCopy.text("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: 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. + 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/PushOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/PushOperations.swift new file mode 100644 index 0000000..46207ff --- /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: APILocalCopy.text("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/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/StaffOperations.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/StaffOperations.swift new file mode 100644 index 0000000..eefccca --- /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.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 { + 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/TokenVault.swift b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift new file mode 100644 index 0000000..ef92370 --- /dev/null +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/TokenVault.swift @@ -0,0 +1,55 @@ +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 APIError(status: 0, code: "secure_storage_unavailable_\(status)", detail: vaultError.detail) + } + } +} diff --git a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml index 983ef86..3fb3354 100644 --- a/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml +++ b/Packages/SpaceWorksAPI/Sources/SpaceWorksAPI/openapi-generator-config.yaml @@ -6,3 +6,121 @@ 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 + - 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 + - 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 + - api_v1_public_requests_create + - api_v1_member_makerspaces_loans_renew_create + - api_v1_public_spaces_availability_retrieve + - api_v1_public_spaces_book_create + - api_v1_public_my_bookings_cancel_create + - api_v1_public_events_register_create + - api_v1_member_makerspaces_event_registrations_cancel_create + - api_v1_member_makerspaces_event_registrations_qr_retrieve + - api_v1_member_makerspaces_account_profile_retrieve + - api_v1_member_makerspaces_account_profile_partial_update + - 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 + - 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 + + - 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 + + - 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 + + + - 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 + + - 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/README.md b/README.md index da8e3c1..e436c1f 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,86 @@ +

+ Space Works box logo on its supplied cream background +

+ # 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. +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 | + +| 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 | + +## What is included + +| Area | App flows | +| --- | --- | +| 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 | 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. + +**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 + +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 +python3 scripts/lint.py xcodegen generate -xcodebuild test -project SpaceWorks.xcodeproj -scheme SpaceWorks -destination 'platform=iOS Simulator,name=iPhone 16' -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO +python3 scripts/dependency_lock.py stage +open SpaceWorks.xcodeproj ``` -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. +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. + +[Contributing](CONTRIBUTING.md) ยท [Security reporting](SECURITY.md) ยท [Reviewed dependency lock and tools](Dependencies/README.md) + +| 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 | -`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. +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). Current validation and parity: [CHANGELOG.md](docs/CHANGELOG.md). Platform fonts remain in use pending Gilroy app-embedding clearance. +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/SpaceWorks/AccountAccessState.swift b/SpaceWorks/AccountAccessState.swift new file mode 100644 index 0000000..35f389a --- /dev/null +++ b/SpaceWorks/AccountAccessState.swift @@ -0,0 +1,160 @@ +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() + private(set) var feedback: NativeFeedbackEvent? + let isDemo: Bool + 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 + 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, + 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 = {}, 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.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 { 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 { 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 { 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.") + self.step = .confirmReset + } + } + func confirmReset() async { + guard !busy, now() >= submitAfter else { 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 { 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.") + } + } + func resendEmail() async { + guard canResend, now() >= submitAfter else { return } + await perform(feedback: .selection) { + try await self.resendVerification() + guard self.ownerIsCurrent() else { throw CancellationError() } + self.resendAfter = self.now().addingTimeInterval(60) + 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() { + guard !busy else { return } + 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 { 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) { + if let deadline = APIError.retryDeadline(failure) { + submitAfter = max(submitAfter, deadline); resendAfter = max(resendAfter, deadline) + } + } + 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() + 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 new file mode 100644 index 0000000..06a6610 --- /dev/null +++ b/SpaceWorks/AccountAccessView.swift @@ -0,0 +1,142 @@ +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() + if form.isDemo { Label("Demo mode ยท no account changes are made", systemImage: "info.circle").foregroundStyle(Color.swMuted) } + SWCard { + Text(heading).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text(explanation).foregroundStyle(Color.swMuted) + switch form.step { + case .signUp: + 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(String(localized: "Phone number"), value: $form.phone, id: "account-phone", type: .telephoneNumber) + }.padding(.vertical, 8) + primary(String(localized: "Create account")) { await form.signUp() } + case .requestReset: + 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(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(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") + .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) } + 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) + .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) + .nativeFeedback(form.feedback) + .onDisappear { form.retireFeedback(); form.clearSecrets() } + } + private var heading: String { + switch form.step { + 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 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 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 { + VStack(alignment: .leading, spacing: 8) { + 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() + .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).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) + .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(CooldownCopy.retryMessage(until: form.resendAfter, now: tick.date)) + .brandFont(.caption).foregroundStyle(Color.swMuted) + } + } + } + } +} diff --git a/SpaceWorks/AccountDeletion.swift b/SpaceWorks/AccountDeletion.swift new file mode 100644 index 0000000..3add91d --- /dev/null +++ b/SpaceWorks/AccountDeletion.swift @@ -0,0 +1,268 @@ +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 } + 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)) + } + 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: String) -> Method? 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..048cb5e --- /dev/null +++ b/SpaceWorks/AccountDeletionView.swift @@ -0,0 +1,140 @@ +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: (@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: (@MainActor (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)) + .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") + 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/AccountDemo.swift b/SpaceWorks/AccountDemo.swift new file mode 100644 index 0000000..70714d6 --- /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").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" } } + 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..41ef5a6 --- /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").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.").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 + 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/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/AccountState.swift b/SpaceWorks/AccountState.swift new file mode 100644 index 0000000..b2d32d1 --- /dev/null +++ b/SpaceWorks/AccountState.swift @@ -0,0 +1,383 @@ +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? + /// 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: (@MainActor (MobileLink) -> Void)? + 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..5e796f5 --- /dev/null +++ b/SpaceWorks/AccountViews.swift @@ -0,0 +1,382 @@ +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").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 { + 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").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").brandFont(.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 { liveDeletion } + #else + 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 + } +} + +private struct AccountMenuRow: View { + let title: LocalizedStringKey; let detail: String; let icon: String + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var body: some View { + Group { + if dynamicTypeSize.isAccessibilitySize { + // Decorative glyphs must not squeeze scaled words into narrow columns. + VStack(alignment: .leading, spacing: 8) { + 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).brandFont(.headline); Text(verbatim: detail).brandFont(.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").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").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").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) } + 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").brandFont(.largeTitle, weight: .bold) + Text(message).foregroundStyle(Color.swMuted) + if !codes.isEmpty { + 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") + } + 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").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").brandFont(.headline) } + if let pending = state.pendingPhone { + 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.").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").brandFont(.headline) + TextField("Calling code", text: $state.phoneCountry).keyboardType(.phonePad).modifier(AccountInput()).focused($focused, equals: .country).disabled(state.busy) + 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 + 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) + }.padding(24).frame(maxWidth: 680).frame(maxWidth: .infinity) }.background(Color.swBg).navigationTitle("Phone sign-in").navigationBarTitleDisplayMode(.inline).scrollDismissesKeyboard(.interactively) + .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 = "" } + } +} + +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").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)).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) + } + } + 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").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").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)") + } + } + 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").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").brandFont(.title2, weight: .bold) + AccountFeedback(load: state.grantsLoad) { await state.loadGrants() } + ForEach(state.grants, id: \.id) { row in + SWCard { + 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)") + } + } + + 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).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)).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").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) } + 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 { + if reasonFocused { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { reasonFocused = false } + .accessibilityLabel(Text("Done")).accessibilityIdentifier("account-membership-keyboard-done") + } + } + }.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" ? 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 } + } + } +} + +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").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()).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 + 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").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) + }.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/AppGuideView.swift b/SpaceWorks/AppGuideView.swift new file mode 100644 index 0000000..d3ff06a --- /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").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 { + 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").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").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) + } + }.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/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/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 new file mode 100644 index 0000000..bdd2c41 --- /dev/null +++ b/SpaceWorks/AuthState.swift @@ -0,0 +1,716 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class AuthState { + 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 = "" + 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? + 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 + 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 + private(set) var acceptedCredentials: CredentialSnapshot? + 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? + /// 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? + 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, 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? + 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 && !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 ?? "" } + /// `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 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), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + 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 + } + /// 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 } + busy = true; defer { busy = false } + restored = false; error = nil; await restore() + } + 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 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 + 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 + 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 { + guard !(error is CancellationError) else { return } + self.error = APIError.display(error) + if isUnauthorized(error) { await invalidateSession(message: APIError.display(error)) } + stage = .signIn + } + } + 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."); feedback = .init(.error); return + } + let feedbackGeneration = identityGeneration + 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: 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) + switch result { + 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) + } + } 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) + }) + } + + /// 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 + 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 { + 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 { + 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."); feedback = .init(.error); return } + let feedbackGeneration = identityGeneration + busy = true; error = nil + defer { busy = false; code = "" } + do { + let result = try await service.verify(challenge: mfa.challenge, code: code, enrolling: mfa.enrollment_required) + try await complete(result, generation: feedbackGeneration) + } catch { refuse(error, generation: feedbackGeneration) } + } + 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 + } + func select(_ id: Int) async { + do { + try await service.makerspace.select(id, activeMemberships: Set(memberships.map(\.id))) + selectedID = id + settings.set(id, forKey: selectionKey) + } catch { + try? await service.makerspace.select(nil, activeMemberships: Set(memberships.map(\.id))) + selectedID = nil; fail(error) + } + } + func foreground() async { + 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 { 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) + try? await service.makerspace.select(nil, activeMemberships: []) + error = message + } + func signOut() async { + 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 { + try await service.refresh.install(nil) + await clear() + 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 } + await endRevokedSession(message: 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 { + 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 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 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) }) + 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) + } + }).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) + } + 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 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.")) + } + 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() + 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: 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 + 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) + 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) } + 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. + 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 ?? 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 { + 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 failureHandlers = repositoryFailureHandlers() + let registration = PushRegistrationState(repository: LivePushRegistrationRepository(service: service, expectedCredentials: credentials), + storage: PushRegistrationVault(origin: origin, accountID: accountID), configured: Brand.pushConfigured, + 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 + 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) + } + /// 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() { + let holders = privateHolders; privateHolders = [:] + 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 + user = nil; selectedID = nil; mfa = nil; mfaExpires = nil; enrollment = nil; recoveryCodes = [] + username = ""; password = ""; code = ""; appLock = nil; stage = .signIn + } + private func clear() async { + let cleanup = detachNotificationCleanup() + clearLocalIdentity() + await cleanup.state?.stop(authorizedDirectMembershipIDs: cleanup.ids) + try? await service.makerspace.select(nil, activeMemberships: []) + } + 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..4645535 --- /dev/null +++ b/SpaceWorks/AuthView.swift @@ -0,0 +1,335 @@ +import SwiftUI +import SpaceWorksAPI +import CoreImage.CIFilterBuiltins +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, linkRouter: MobileLinkRouter) { + self.state = state; self.origin = origin; self.linkRouter = linkRouter + _auth = State(initialValue: AuthState(origin: origin)) + } + var body: some View { + 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 .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: + AccountSecurityCompletionView(codes: auth.recoveryCodes, message: auth.securityMessage ?? "", continueToSignIn: auth.finishSecurityCompletion) + case .signedIn: + if auth.selectedID == nil { AccountMakerspaceLanding(auth: auth) } + else { AppShell(state: state, auth: auth, linkRouter: linkRouter) } + } + } + } + .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. + .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() } } } + .onDisappear { linkRouter.unbind(owner: routingOwner); Task { await auth.stopNotifications() } } + .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) } + 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: String(localized: "This update isn't available to your account.")) + } + return legacy.link(makerspaceID: membership.id) + }, owner: routingOwner) + Task { await linkRouter.process() } + } else { linkRouter.unbind(owner: routingOwner) } + } +} + +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").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") + 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).onDisappear { current = ""; new = ""; confirmation = "" } + } +} + +struct SWCard: View { + 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(contentPadding).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 { 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 { + func makeBody(configuration: Configuration) -> some View { + 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)) + } +} +struct SignInView: View { + @Bindable var auth: AuthState + let state: AppState + @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 + ScrollView { + HStack(alignment: .center, spacing: 40) { + VStack(alignment: .leading, spacing: 24) { + BrandWordmark(compact: geometry.size.height < 600) + Text("Welcome back.").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("Sign in to your makerspace account.").foregroundStyle(Color.swMuted) + SWCard { + 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 && !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: $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) } + if auth.passwordEnabled { + field("Email or 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 { + 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: 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 { + 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) + 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 { + 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.").brandFont(.title3) + }.padding(40).frame(maxWidth: 460, minHeight: 460, alignment: .leading) + .background(Color.swSurface, in: RoundedRectangle(cornerRadius: 28)).foregroundStyle(Color.swInk) + .accessibilityHidden(true) + } + }.padding(24).frame(maxWidth: .infinity, minHeight: geometry.size.height) + }.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) { + 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)) + } + } +} + +struct BrandWordmark: View { + var compact = false + var spacious = false + @Environment(\.verticalSizeClass) private var verticalSize + @Environment(\.dynamicTypeSize) private var typeSize + var body: some View { + 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(Text(verbatim: Brand.appName)) + } else { + 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).brandFont(.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 { + Image(systemName: "lock.shield").font(.largeTitle).foregroundStyle(Color.swAccentInk).accessibilityHidden(true) + if auth.stage == .recoveryCodes { + 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").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) + 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(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) + .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") + 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) + } +} +struct QRCodeView: View { + let value: String + 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) + .padding(12).background(Color.white).accessibilityLabel(label) + } + } + 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 account: AccountState? = nil + @State private var emailForm: AccountAccessState? + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + 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).brandFont(.title2, weight: .bold) + Text(membership.role_name).foregroundStyle(Color.swMuted) + } + }.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).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 new file mode 100644 index 0000000..46c086a --- /dev/null +++ b/SpaceWorks/BookingState.swift @@ -0,0 +1,54 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class BookingState { + let repository: any MemberActionsRepository + let space: Components.Schemas.PublicBookableSpace + 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: MemberBookingCreated? + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private var generation = 0 + 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 = String(localized: "Choose an end time in the future and after the start time."); return false } + return true + } + func check(now: Date = Date()) async { + guard active, !busy, (retryAfter ?? .distantPast) <= now, valid(now: now) else { return } + busy = true; error = nil; availability = nil; result = nil; let expected = generation + defer { busy = false } + do { + let response = try await repository.availability(space: space.public_token, start: start, end: end) + guard active, expected == generation else { return }; availability = response + } catch { + guard active, expected == generation else { return }; self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) + } + } + func confirm(input: Components.Schemas.PublicBookingInput? = nil, now: Date = Date()) async { + guard active, !busy, availability != nil, (retryAfter ?? .distantPast) <= now, valid(now: now) else { return } + var submission = input ?? .init(starts_at: start, ends_at: end) + submission.starts_at = start; submission.ends_at = end + busy = true; error = nil; let expected = generation + defer { busy = false } + do { + let response = try await repository.book(space: space.public_token, input: submission) + guard active, expected == generation else { return }; result = response + } catch { + guard active, expected == generation else { return }; self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) + if [409, 423, 503].contains(APIError.status(error) ?? 0) { availability = nil } + } + } + func startAnother() { + guard active, !busy else { return }; start = Date().addingTimeInterval(3600); end = Date().addingTimeInterval(7200) + availability = nil; result = nil; error = nil + } + func invalidate() { active = false; generation += 1; availability = nil; result = nil; error = nil } + private func changed() { generation += 1; availability = nil; result = nil; error = nil } +} diff --git a/SpaceWorks/BookingViews.swift b/SpaceWorks/BookingViews.swift new file mode 100644 index 0000000..9122e54 --- /dev/null +++ b/SpaceWorks/BookingViews.swift @@ -0,0 +1,157 @@ +import SwiftUI +import SpaceWorksAPI + +struct SpaceDiscoveryView: View { + let member: MemberState + let repository: any MemberActionsRepository + let isDemo: Bool + let completed: () async -> Void + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + 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").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").brandFont(.title2, weight: .bold) + Text(space.description).foregroundStyle(Color.swMuted) + Text(space.location).foregroundStyle(Color.swMuted) + Text("Capacity: \(space.capacity)") + Label("View availability", systemImage: "arrow.right").foregroundStyle(Color.swAccentInk) + } + }.buttonStyle(.plain).accessibilityIdentifier("space-\(space.public_token)") + } + }.padding(24).frame(maxWidth: 880, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Spaces").task { await member.loadSpaces() } + } +} + +struct SpaceBookingView: View { + @State private var state: BookingState + @State private var form: MemberCustomForm + @State private var formError: String? + let isDemo: Bool + let completed: () async -> Void + let viewActivity: () -> Void + init(repository: any MemberActionsRepository, space: Components.Schemas.PublicBookableSpace, isDemo: Bool, drafts: MemberActionDrafts? = nil, viewActivity: @escaping () -> Void = {}, completed: @escaping () async -> Void) { + _state = State(initialValue: drafts?.booking(space) ?? BookingState(repository: repository, space: space)) + _form = State(initialValue: drafts?.form(space) ?? MemberCustomForm(data: space.custom_form.flatMap { try? JSONEncoder().encode($0) })) + self.isDemo = isDemo; self.completed = completed; self.viewActivity = viewActivity + } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + 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.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) + } + } else { + SWCard { + 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 + Button(state.busy ? "Checkingโ€ฆ" : "Check availability") { Task { await state.check() } }.buttonStyle(SWPrimary()) + .disabled(state.busy || (state.retryAfter ?? .distantPast) > tick.date) + } + }.disabled(state.busy) + if let availability = state.availability { + SWCard { + 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").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").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 + Button(state.busy ? "Submittingโ€ฆ" : "Submit booking request") { Task { await book() } }.buttonStyle(SWPrimary()) + .disabled(state.busy || form.unavailable || (state.retryAfter ?? .distantPast) > tick.date) + } + }.disabled(state.busy) + } + if let error = state.error { SWError(message: error) } + } + }.padding(24).frame(maxWidth: 760, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Book a space").navigationBarBackButtonHidden(state.busy).scrollDismissesKeyboard(.interactively) + } + private func book() async { + formError = nil + 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 = String(localized: "These booking details could not be prepared. Check your answers and try again.") } + } +} + +private func intervalLabel(_ start: Date, _ end: Date, timezone: String) -> String { + let format = DateFormatter(); format.locale = .current; format.dateStyle = .medium; format.timeStyle = .short + format.timeZone = TimeZone(identifier: timezone) ?? TimeZone(secondsFromGMT: 0) + return format.string(from: start) + " โ€“ " + format.string(from: end) +} + +struct MemberBookingDetailView: View { + let booking: Components.Schemas.MemberBooking + let repository: any MemberActionsRepository + let isDemo: Bool + let completed: () async -> Void + @State private var updated: Components.Schemas.MemberBooking? + @State private var error: String? + @State private var busy = false + @State private var confirmation = false + @State private var active = true + @State private var retryAfter: Date? + private var current: Components.Schemas.MemberBooking { updated ?? booking } + var body: some View { + ScrollView { + SWCard { + 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).brandFont(.caption).foregroundStyle(Color.swMuted) + Text(NativeStatusCopy.label(current.status.value1.rawValue)).brandFont(.headline) + 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) + TimelineView(.periodic(from: .now, by: 1)) { tick in + Button(busy ? "Cancellingโ€ฆ" : "Cancel booking", role: .destructive) { confirmation = true }.frame(minHeight: 48) + .disabled(busy || (retryAfter ?? .distantPast) > tick.date) + } + } else { Text("Cancellation isn't available for this booking.").foregroundStyle(Color.swMuted) } + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Booking details").navigationBarBackButtonHidden(busy).onDisappear { active = false } + .confirmationDialog("Cancel this booking?", isPresented: $confirmation, titleVisibility: .visible) { + Button("Cancel booking", role: .destructive) { Task { await cancel() } } + Button("Keep booking", role: .cancel) {} + } message: { Text("Your makerspace checks the cancellation cutoff. This does not automatically refund a payment.") } + } + private func cancel() async { + guard active, !busy, current.can_cancel, (retryAfter ?? .distantPast) <= Date() else { return } + busy = true; error = nil; defer { busy = false } + do { let value = try await repository.cancelBooking(token: current.public_token); guard active else { return }; updated = value; await completed() } + catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } +} diff --git a/SpaceWorks/BrandTypography.swift b/SpaceWorks/BrandTypography.swift new file mode 100644 index 0000000..7ace982 --- /dev/null +++ b/SpaceWorks/BrandTypography.swift @@ -0,0 +1,59 @@ +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 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 + let font = unscaledFont(size: base, weight: weight, boldText: boldText) + 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/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/DebugAuthFixtures.swift b/SpaceWorks/DebugAuthFixtures.swift new file mode 100644 index 0000000..0a2df4a --- /dev/null +++ b/SpaceWorks/DebugAuthFixtures.swift @@ -0,0 +1,34 @@ +#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_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: ","))]}" + } + 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 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/DemoAccountLandingView.swift b/SpaceWorks/DemoAccountLandingView.swift new file mode 100644 index 0000000..307abf2 --- /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").brandFont(.largeTitle, weight: .bold) + Text("You havenโ€™t joined a makerspace yet.").foregroundStyle(Color.swMuted) + SWCard { + 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") + 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..e18a9df --- /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").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.").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 { + 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").brandFont(.title2, weight: .bold) + ForEach(state.blockers) { blocker in + SWCard { + Text(blocker.makerspace).brandFont(.headline) + Text(blocker.detail) + 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").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 { + 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").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").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 = "" } + .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)).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)) + } +} +#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/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/DemoMemberActionsRepository.swift b/SpaceWorks/DemoMemberActionsRepository.swift new file mode 100644 index 0000000..4db1c86 --- /dev/null +++ b/SpaceWorks/DemoMemberActionsRepository.swift @@ -0,0 +1,153 @@ +#if DEBUG +import Foundation +import SpaceWorksAPI + +/// All mutations are in-memory sample records; nothing crosses a URLSession or SDK boundary. +actor DemoMemberStore { + var activityJSON = DebugMemberFixtures.activity + var bookingsJSON = DebugMemberFixtures.bookings + func activity() -> String { activityJSON } + func bookings() -> String { bookingsJSON } + func request(_ count: Int) throws { + var value = try object(activityJSON) + var rows = value["request_history"] as? [[String: Any]] ?? [] + rows.insert(["status": "pending", "created_at": ISO8601DateFormatter().string(from: Date()), "item_count": count, + "returned_quantity": 0, "damaged_quantity": 0, "missing_quantity": 0], at: 0) + value["request_history"] = rows; activityJSON = try json(value) + } + 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: 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 + } + func booked(_ input: Components.Schemas.PublicBookingInput, status: String) throws { + 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, "confirmation_available": false], at: 0) + value["results"] = rows; value["count"] = rows.count; bookingsJSON = try json(value) + } + func registered(status: String) throws { + var value = try object(activityJSON) + var rows = value["event_registrations"] as? [[String: Any]] ?? [] + rows.removeAll { $0["registration_id"] as? Int == 777 } + rows.insert(["registration_id": 777, "checkin_token": status == "registered" ? UUID().uuidString as Any : NSNull(), "event_title": "Make your first circuit", + "starts_at": "2026-10-20T09:00:00Z", "ends_at": "2026-10-20T11:00:00Z", "status": status, "cancellation_cutoff_at": "2026-10-20T08:00:00Z", + "can_cancel": true, "waitlist_position": status == "waitlisted" ? 3 as Any : NSNull(), "feedback_available": false, "feedback_path": NSNull(), "certificate": NSNull()], at: 0) + value["event_registrations"] = rows; activityJSON = try json(value) + } + 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: 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: 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) + } + private func object(_ value: String) throws -> [String: Any] { try JSONSerialization.jsonObject(with: Data(value.utf8)) as! [String: Any] } + private func json(_ value: Any) throws -> String { String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self) } +} + +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 + private var savedPreferences = Components.Schemas.PreferenceResponse(cells: [ + .init(feature: .hardware_requests, channel: .email, enabled: true, daily_digest: false, available: true, mandatory_events: ["loan_overdue"]), + .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, 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: 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 { + 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: String(localized: "This request changed. Review it again.")) } + return existing.1 + } + let result = Components.Schemas.RequestSubmitResponse(public_token: UUID().uuidString, status: "pending") + receipts[key] = (payload, result); try await store.request(items.reduce(0) { $0 + $1.quantity }); return result + } + func renew(request: Int) async throws -> Components.Schemas.LoanRenewal { + try check(); let due = Date().addingTimeInterval(14 * 86400) + let count = try await store.renewed(request, due: due); return .init(id: request, return_due_at: due, renewal_count: count) + } + 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 -> 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 { + try check(); let status: Components.Schemas.PublicEventRegistrationResponseStatusEnum = scenario == .waitlisted ? .waitlisted : scenario == .pending ? .pending_approval : .registered + try await store.registered(status: status.rawValue); return .init(status: .init(value1: status)) + } + func cancelEvent(registration: Int) async throws -> Components.Schemas.MemberRegistrationCancelResponse { + try check(); try await store.cancelled(registration); return .init(status: .init(value1: .cancelled)) + } + func eventQR(registration: Int) async throws -> Data { + try check() + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + 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: String(localized: "A ticket is available only after registration is confirmed.")) + } + return Data(#""#.utf8) + } + func profile() async throws -> Components.Schemas.AccountProfile { try check(); return savedProfile } + func updateProfile(_ input: Components.Schemas.PatchedAccountProfile) async throws -> Components.Schemas.AccountProfile { + try check(); savedProfile = .init(display_name: input.display_name ?? savedProfile.display_name, first_name: input.first_name ?? savedProfile.first_name, + last_name: input.last_name ?? savedProfile.last_name, phone: input.phone ?? savedProfile.phone); return savedProfile + } + 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: 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: String(localized: "This option isn't available in this makerspace.")) } + cell.enabled = enabled + } + if let daily = input.daily_digest { cell.daily_digest = daily } + savedPreferences.cells[index] = cell; return savedPreferences + } +} +#endif diff --git a/SpaceWorks/DemoMemberRepository.swift b/SpaceWorks/DemoMemberRepository.swift new file mode 100644 index 0000000..25cd2f0 --- /dev/null +++ b/SpaceWorks/DemoMemberRepository.swift @@ -0,0 +1,73 @@ +#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() + let store: DemoMemberStore? + init(scenario: Scenario = .populated, name: String = "Meera Nair", store: DemoMemberStore? = nil) { self.scenario = scenario; self.name = name; self.store = store } + 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: 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) } + func activity() async throws -> Components.Schemas.MemberActivity { + let source = await store?.activity() ?? DebugMemberFixtures.activity + var object = try JSONSerialization.jsonObject(with: Data(source.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":[]}"# : (await store?.bookings() ?? 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: 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 } + 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 new file mode 100644 index 0000000..a845420 --- /dev/null +++ b/SpaceWorks/DemoMode.swift @@ -0,0 +1,474 @@ +#if DEBUG +import SwiftUI +import Observation +import SpaceWorksAPI + +@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", expired = "Expired challenge" + 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, noMakerspaces, securityCompletion, emailVerification } + var role = Role.member + var scenario = Scenario.password + var stage = Stage.welcome + var username = "" + var password = "" + var code = "" + 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] = [:] + @ObservationIgnored var guidanceFlags: [DemoGuidanceKey: Bool] = [:] + @ObservationIgnored var guidanceEpoch = UUID() + 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 + 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 } + if arguments.contains("--demo-action-pending") { actionScenario = .pending } + if arguments.contains("--demo-action-waitlisted") { actionScenario = .waitlisted } + if arguments.contains("--demo-action-conflict") { actionScenario = .conflict } + if arguments.contains("--demo-action-refused") { actionScenario = .refused } + if arguments.contains("--demo-action-throttled") { actionScenario = .throttled } + } + } + func signIn() { + 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 = 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 .noMemberships: availableSpaces = []; stage = .noMakerspaces + case .password: stage = membershipStage + case .mfa, .enrollment: stage = .mfa + } + } + func verify() { + 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; 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() { 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 { + case google = "Google", apple = "Apple", organization = "your organization", phone = "phone" + var id: Self { self } +} + +struct DemoRoot: View { + let exit: () -> Void + @State private var session = DemoSession() + @State private var linkRouter = MobileLinkRouter() + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Demo mode ยท sample data").brandFont(.caption, weight: .bold) + Spacer() + Button { + NativeDemoEntryDiagnostics.record(.exitButtonInvoked) + 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)") + } + 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) } + .onChange(of: linkRouter.revision) { _, _ in Task { await linkRouter.process() } } + .onChange(of: session.stage) { _, _ in bindRouting() } + .onAppear { NativeDemoEntryDiagnostics.record(.demoRootAppeared) } + .onDisappear { linkRouter.reset(); NativeDemoEntryDiagnostics.record(.demoRootDisappeared) } + } + 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 Set(session.availableSpaces.map { $0 + 7 }) + }, select: { id in + 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], session.availableSpaces.contains(id - 7) else { throw CancellationError() } + return legacy.link(makerspaceID: id) + }) + Task { await linkRouter.process() } + } +} + +struct DemoAuthView: View { + @Bindable var session: DemoSession + @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) { + BrandWordmark() + SWCard { + 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") + 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.").brandFont(.largeTitle, weight: .bold) + Text("Your makerspace, in your pocket.").foregroundStyle(Color.swMuted) + 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").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.").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") + .disabled((session.retryAfter ?? .distantPast) > context.date) + } + 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").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").brandFont(.headline) + 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() + .textContentType(.oneTimeCode).accessibilityIdentifier("demo-mfa-code") + 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").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").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) + 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").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").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() + } + } + }.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, isDemo: true, 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 state = DemoProviderState() + var body: some View { + NavigationStack { + ScrollView { + SWCard { + 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").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").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.").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) + } + Button("Change number") { state.changeNumber() }.frame(minHeight: 48) + } + } else { + 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) } + 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: 48) } } + } + } +} + +struct DemoShell: View { + let session: DemoSession + let linkRouter: MobileLinkRouter + @State private var member: MemberState + @Environment(\.horizontalSizeClass) private var sizeClass + @State private var navigation = MemberNavigation() + @State private var cart: RequestCartState + @State private var drafts: MemberActionDrafts + @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 + @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)) + _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, 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: session.selectedSpace + 7)) + _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 + VStack(spacing: 0) { + // 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) + .foregroundStyle(navigation.selected == tab ? Color.swOnAccent : Color.swInk) + }.scrollContentBackground(.hidden).background(Color.swSidebar).navigationTitle(Brand.appName) + } detail: { destination(navigation.selected) } + } else { + TabView(selection: $navigation.selected) { + ForEach(AppTab.allCases) { tab in + destination(tab) + .tabItem { Label(LocalizedStringKey(tab.rawValue), systemImage: tab.symbol) }.tag(tab) + .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) + } + } + } + }.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 + } + }.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 + try await PaymentsLinkDestination.open(id, state: payments, navigation: navigation) { linkRouter.isCurrent(approved) } + }) + } + } + .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, 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, account: account, onDemoDelete: session.reset, onDemoFix: session.routeAccountBlocker) { + if let profile = try? await actions.profile() { session.profileDisplayName = profile.display_name } + } + } + } + } +} +struct DemoDestination: View { + let session: DemoSession + let member: MemberState + let tab: AppTab + let actions: DemoMemberActionsRepository + let cart: RequestCartState + let navigation: MemberNavigation + let capabilities: MemberCapabilities + let drafts: MemberActionDrafts + let payments: PaymentsState + let staff: StaffState? + let account: AccountState + 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, 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) } + else { linkRouter.refuseURL() } + }) } + else { ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Welcome back, \(session.name).").brandFont(.largeTitle, weight: .bold) + Text(session.spaces[session.selectedSpace]).foregroundStyle(Color.swMuted) + SWCard { + 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) } + 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") { + 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(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) + } + }.padding(24).frame(maxWidth: 960).frame(maxWidth: .infinity) + } } + }.background(Color.swBg).navigationTitle(LocalizedStringKey(tab.rawValue)) + .toolbar { + 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") }.accessibilityIdentifier("demo-makerspace-switcher") + } + } + } +} +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/DemoNotificationsView.swift b/SpaceWorks/DemoNotificationsView.swift new file mode 100644 index 0000000..0454aae --- /dev/null +++ b/SpaceWorks/DemoNotificationsView.swift @@ -0,0 +1,58 @@ +#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 { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Bindable var state: DemoNotificationState + let makerspaceID: Int + let route: (MobileLink) -> Void + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SWCard(contentPadding: dynamicTypeSize.isAccessibilitySize ? 12 : 24) { + 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 { + 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." } + .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").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") + 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 = String(localized: "Demo notification settings refreshed. No notifications were sent.") }.frame(minHeight: 48) + }.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/DemoProviderState.swift b/SpaceWorks/DemoProviderState.swift new file mode 100644 index 0000000..53b1f81 --- /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 = String(localized: "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 = 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 = String(localized: "That code didn't work. Check it and try again."); return false } + code = ""; return true + } + } +} +#endif diff --git a/SpaceWorks/EventRegistrationState.swift b/SpaceWorks/EventRegistrationState.swift new file mode 100644 index 0000000..f6f62e0 --- /dev/null +++ b/SpaceWorks/EventRegistrationState.swift @@ -0,0 +1,48 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class EventRegistrationState { + let repository: any MemberActionsRepository + let event: Components.Schemas.PublicEvent + let form: MemberCustomForm + var phone = "" + var email = "" + var invite = "" + var promo = "" + var ticket: Int? + private(set) var result: Components.Schemas.PublicEventRegistrationResponse? + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private var active = true + init(repository: any MemberActionsRepository, event: Components.Schemas.PublicEvent) { + self.repository = repository; self.event = event + form = MemberCustomForm(data: event.custom_form.flatMap { try? JSONEncoder().encode($0) }) + } + func submit(now: Date = Date()) async { + guard active, !busy, result == nil, (retryAfter ?? .distantPast) <= now else { 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 = 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) + } + if let ticket { body["ticket_type_id"] = ticket } + busy = true; error = nil; defer { busy = false } + do { + let input = try JSONDecoder().decode(Components.Schemas.PublicEventRegistrationInput.self, from: JSONSerialization.data(withJSONObject: body)) + let response = try await repository.register(event: event.public_token, input: input) + guard active else { return }; result = response + } catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } + func resetReceipt() { if active && !busy { result = nil; error = nil } } + func invalidate() { active = false; result = nil; phone = ""; email = ""; invite = ""; promo = ""; form.text = [:]; form.multiple = [:]; error = nil } +} diff --git a/SpaceWorks/EventViews.swift b/SpaceWorks/EventViews.swift new file mode 100644 index 0000000..44de9c6 --- /dev/null +++ b/SpaceWorks/EventViews.swift @@ -0,0 +1,121 @@ +import SwiftUI +import SpaceWorksAPI + +struct EventDetailView: View { + let event: Components.Schemas.PublicEvent + let repository: any MemberActionsRepository + let isDemo: Bool + let completed: () async -> Void + let viewActivity: () -> Void + @State private var state: EventRegistrationState + init(event: Components.Schemas.PublicEvent, repository: any MemberActionsRepository, isDemo: Bool, drafts: MemberActionDrafts? = nil, viewActivity: @escaping () -> Void = {}, completed: @escaping () async -> Void) { + self.event = event; self.repository = repository; self.isDemo = isDemo; self.completed = completed; self.viewActivity = viewActivity + _state = State(initialValue: drafts?.event(event) ?? EventRegistrationState(repository: repository, event: event)) + } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + 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").brandFont(.title2, weight: .bold); Text(event.description) } + if let result = state.result { + SWCard { + 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.").brandFont(.caption).foregroundStyle(Color.swMuted) + Button("View registrations", action: viewActivity).buttonStyle(SWPrimary()) + } + } else if !event.registration_open { SWCard { Text("Registration is closed.").brandFont(.headline) } } + else { + SWCard { + 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) { + 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 ?? String(localized: "Ticket")) + (ticket.amount.map { " ยท \(event.currency) \($0)" } ?? "")).tag(Int?.some(id)) + } + } + }.frame(minHeight: 48) + } + 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 + Button(state.busy ? "Submittingโ€ฆ" : "Confirm registration") { + Task { await state.submit(); if state.result != nil { await completed() } } + }.buttonStyle(SWPrimary()).disabled(state.busy || state.form.unavailable || (state.retryAfter ?? .distantPast) > tick.date) + } + }.disabled(state.busy) + } + }.padding(24).frame(maxWidth: 760, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Event details").scrollDismissesKeyboard(.interactively) + .navigationBarBackButtonHidden(state.busy) + } + private func input(_ label: String, value: Binding) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label).brandFont(.headline) + TextField(label, text: value).padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)) + } + } + private func resultTitle(_ value: String) -> String { + 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") } + } +} + +struct MemberRegistrationView: View { + let registration: Components.Schemas.MemberEventRegistrationActivity + let isDemo: Bool + let completed: () async -> Void + @Environment(\.scenePhase) private var scenePhase + @State private var state: MemberTicketState + @State private var confirmCancel = false + init(registration: Components.Schemas.MemberEventRegistrationActivity, repository: any MemberActionsRepository, isDemo: Bool, drafts: MemberActionDrafts? = nil, completed: @escaping () async -> Void) { + self.registration = registration; self.isDemo = isDemo; self.completed = completed + _state = State(initialValue: drafts?.ticket(registration) ?? MemberTicketState(registration: registration, repository: repository)) + } + var body: some View { + ScrollView { + SWCard { + 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)).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").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 { + if !state.cancelled && registration.status == "registered" { LoadFeedback(load: .failed(error), retryAfter: state.retryAfter) { await state.load() } } + else { SWError(message: error) } + } + if !state.cancelled && registration.can_cancel { + 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) + } + } + }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Event registration").privacySensitive() + .task { await state.load() } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await state.load() } } } + .confirmationDialog("Cancel this registration?", isPresented: $confirmCancel, titleVisibility: .visible) { + Button("Cancel registration", role: .destructive) { Task { await state.cancel(); if state.cancelled { await completed() } } } + Button("Keep registration", role: .cancel) {} + } message: { Text("Your makerspace checks the cancellation cutoff. A cancellation does not automatically refund a settled payment.") } + } +} 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/IntroductionView.swift b/SpaceWorks/IntroductionView.swift new file mode 100644 index 0000000..aa0af32 --- /dev/null +++ b/SpaceWorks/IntroductionView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct IntroductionView: View { + let finish: () -> Void + @State private var page = 0 + @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."), + 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"]] + 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 { + 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") { NativeFeedback.play(.success); 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]).brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text(details[page]).brandFont(.title3).foregroundStyle(Color.swMuted) + }.id(page).transition(reduceMotion ? .identity : .opacity.combined(with: .offset(y: 8))) + Spacer(minLength: 16) + Button(page == 2 ? "Get started" : "Next") { + 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") { 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) + .frame(maxWidth: .infinity) + }.background(Color.swBg) + } + } +} diff --git a/SpaceWorks/LiveMemberActionsRepository.swift b/SpaceWorks/LiveMemberActionsRepository.swift new file mode 100644 index 0000000..0177582 --- /dev/null +++ b/SpaceWorks/LiveMemberActionsRepository.swift @@ -0,0 +1,56 @@ +import Foundation +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 { + 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() } + throw error + } + } + func bootstrap() async throws -> Components.Schemas.TenantBootstrap { try await checked { try await service.memberBootstrap(slug: slug) } } + func submit(items: [Components.Schemas.RequestItemInput], purpose: String, key: String) async throws -> Components.Schemas.RequestSubmitResponse { + try await checked { try await service.submitMemberRequest(slug: slug, items: items, purpose: purpose, idempotencyKey: key) } + } + func renew(request: Int) async throws -> Components.Schemas.LoanRenewal { + try await checked { try await service.renewMemberLoan(id: id, requestID: request) } + } + 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 -> MemberBookingCreated { + try await checked { try await service.createMemberBooking(slug: slug, token: space, input: input) } + } + func cancelBooking(token: String) async throws -> Components.Schemas.MemberBooking { + try await checked { try await service.cancelMemberBooking(slug: slug, token: token) } + } + func register(event: String, input: Components.Schemas.PublicEventRegistrationInput) async throws -> Components.Schemas.PublicEventRegistrationResponse { + try await checked { try await service.registerMemberEvent(slug: slug, token: event, input: input) } + } + func cancelEvent(registration: Int) async throws -> Components.Schemas.MemberRegistrationCancelResponse { + try await checked { try await service.cancelMemberEvent(id: id, registration: registration) } + } + func eventQR(registration: Int) async throws -> Data { + try await checked { try await service.memberEventQR(id: id, registration: registration) } + } + func profile() async throws -> Components.Schemas.AccountProfile { try await checked { try await service.memberProfile(id: id) } } + func updateProfile(_ input: Components.Schemas.PatchedAccountProfile) async throws -> Components.Schemas.AccountProfile { + try await checked { try await service.updateMemberProfile(id: id, input: input) } + } + func preferences() async throws -> Components.Schemas.PreferenceResponse { try await checked { try await service.communicationPreferences(id: id) } } + func updatePreference(_ input: Components.Schemas.PatchedPreferenceWrite) async throws -> Components.Schemas.PreferenceResponse { + try await checked { try await service.updateCommunicationPreference(id: id, input: input) } + } +} diff --git a/SpaceWorks/LiveMemberRepository.swift b/SpaceWorks/LiveMemberRepository.swift new file mode 100644 index 0000000..3c27425 --- /dev/null +++ b/SpaceWorks/LiveMemberRepository.swift @@ -0,0 +1,48 @@ +import Foundation +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 { + 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() } + 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/MemberActionDrafts.swift b/SpaceWorks/MemberActionDrafts.swift new file mode 100644 index 0000000..532ee67 --- /dev/null +++ b/SpaceWorks/MemberActionDrafts.swift @@ -0,0 +1,43 @@ +import Foundation +import SpaceWorksAPI + +/// Drafts survive phone/tablet layout reconstruction, and end with their account/makerspace. +@MainActor final class MemberActionDrafts { + let repository: any MemberActionsRepository + private var tickets: [Int: MemberTicketState] = [:] + private var products: [Int: MemberProductState] = [:] + private var bookings: [String: BookingState] = [:] + private var forms: [String: MemberCustomForm] = [:] + private var events: [String: EventRegistrationState] = [:] + let profile: ProfileEditorState + let preferences: CommunicationPreferencesState + init(repository: any MemberActionsRepository) { self.repository = repository; profile = ProfileEditorState(repository: repository); preferences = CommunicationPreferencesState(repository: repository) } + func ticket(_ registration: Components.Schemas.MemberEventRegistrationActivity) -> MemberTicketState { + if let state = tickets[registration.registration_id], state.registration == registration { return state } + tickets[registration.registration_id]?.invalidate() + let state = MemberTicketState(registration: registration, repository: repository); tickets[registration.registration_id] = state; return state + } + func registrationCancelled() { events.values.forEach { $0.resetReceipt() } } + func product(_ id: Int, repository: any MemberRepository) -> MemberProductState { + if let state = products[id] { return state } + let state = MemberProductState(id: id) { try await repository.product(id) }; products[id] = state; return state + } + func booking(_ space: Components.Schemas.PublicBookableSpace) -> BookingState { + if let state = bookings[space.public_token] { return state } + let state = BookingState(repository: repository, space: space); bookings[space.public_token] = state; return state + } + func form(_ space: Components.Schemas.PublicBookableSpace) -> MemberCustomForm { + if let form = forms[space.public_token] { return form } + let form = MemberCustomForm(data: space.custom_form.flatMap { try? JSONEncoder().encode($0) }); forms[space.public_token] = form; return form + } + func event(_ event: Components.Schemas.PublicEvent) -> EventRegistrationState { + if let state = events[event.public_token] { return state } + let state = EventRegistrationState(repository: repository, event: event); events[event.public_token] = state; return state + } + func invalidate() { + tickets.values.forEach { $0.invalidate() }; tickets = [:] + products.values.forEach { $0.invalidate() }; products = [:] + bookings.values.forEach { $0.invalidate() }; events.values.forEach { $0.invalidate() }; profile.invalidate(); preferences.invalidate() + bookings = [:]; forms = [:]; events = [:] + } +} diff --git a/SpaceWorks/MemberActionViews.swift b/SpaceWorks/MemberActionViews.swift new file mode 100644 index 0000000..8e49102 --- /dev/null +++ b/SpaceWorks/MemberActionViews.swift @@ -0,0 +1,111 @@ +import SwiftUI +import SpaceWorksAPI + +struct RequestCartView: View { + @Bindable var state: RequestCartState + let isDemo: Bool + let completed: () async -> Void + @Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + 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").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").brandFont(.headline) + Text(receipt.public_token).brandFont(.body, weight: .medium).monospacedDigit().textSelection(.enabled) + Button("Done") { dismiss() }.buttonStyle(SWPrimary()) + } + } else { + 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).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)").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.").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 + submission(at: tick.date) + } + } else { + submission(at: .now) + } + } + } + }.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).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) } + 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 { + let loan: Components.Schemas.MemberLoanActivity + let repository: any MemberActionsRepository + let isDemo: Bool + let completed: () async -> Void + @Environment(\.dismiss) private var dismiss + @State private var result: Components.Schemas.LoanRenewal? + @State private var error: String? + @State private var busy = false + @State private var retryAfter: Date? + var body: some View { + NavigationStack { + ScrollView { + SWCard { + 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").brandFont(.headline) + Text(result.return_due_at.formatted(date: .abbreviated, time: .shortened)) + Button("Done") { dismiss() }.buttonStyle(SWPrimary()) + } else { + 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) } + 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() } + catch { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } + } +} diff --git a/SpaceWorks/MemberActionsState.swift b/SpaceWorks/MemberActionsState.swift new file mode 100644 index 0000000..761cbf4 --- /dev/null +++ b/SpaceWorks/MemberActionsState.swift @@ -0,0 +1,78 @@ +import Foundation +import Observation +import SpaceWorksAPI + +protocol MemberActionsRepository: Sendable { + func bootstrap() async throws -> Components.Schemas.TenantBootstrap + 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 -> 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 + func eventQR(registration: Int) async throws -> Data + func profile() async throws -> Components.Schemas.AccountProfile + func updateProfile(_ input: Components.Schemas.PatchedAccountProfile) async throws -> Components.Schemas.AccountProfile + func preferences() async throws -> Components.Schemas.PreferenceResponse + func updatePreference(_ input: Components.Schemas.PatchedPreferenceWrite) async throws -> Components.Schemas.PreferenceResponse +} + +/// Review state is local UI data; submission/approval/status always come from generated results. +@MainActor @Observable +final class RequestCartState { + struct Line: Identifiable { + let product: Components.Schemas.PublicProduct + var quantity: Int + var id: Int { product.id } + } + private(set) var lines: [Line] = [] + var purpose = "" { didSet { if purpose != oldValue { changed() } } } + private(set) var receipt: Components.Schemas.RequestSubmitResponse? + private(set) var busy = false + 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 + init(repository: any MemberActionsRepository) { self.repository = repository } + 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 = String(localized: "Choose up to 99 of each item."); return } + lines[index].quantity += 1 + } else { + 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() + } + func quantity(_ value: Int, for id: Int) { + guard !busy, active, let index = lines.firstIndex(where: { $0.id == id }), (0...99).contains(value) else { return } + if value == 0 { lines.remove(at: index) } else { lines[index].quantity = value } + changed() + } + func submit(now: Date = Date()) async { + guard active, !busy, (retryAfter ?? .distantPast) <= now else { 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) } + defer { busy = false } + do { + 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; 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() { 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/MemberBookingsView.swift b/SpaceWorks/MemberBookingsView.swift new file mode 100644 index 0000000..454c2fd --- /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").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 { + 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)).brandFont(.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/MemberCapabilities.swift b/SpaceWorks/MemberCapabilities.swift new file mode 100644 index 0000000..e365fa2 --- /dev/null +++ b/SpaceWorks/MemberCapabilities.swift @@ -0,0 +1,28 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable final class MemberCapabilities { + private(set) var bootstrap: Components.Schemas.TenantBootstrap? + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private var active = true + let repository: any MemberActionsRepository + 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 { + guard active, !busy, (retryAfter ?? .distantPast) <= now else { return } + 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: 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) } + } + func invalidate() { active = false; bootstrap = nil; error = nil } +} diff --git a/SpaceWorks/MemberCustomForm.swift b/SpaceWorks/MemberCustomForm.swift new file mode 100644 index 0000000..59a325a --- /dev/null +++ b/SpaceWorks/MemberCustomForm.swift @@ -0,0 +1,135 @@ +import SwiftUI +import Observation + +/// A native presentation projection of the frontend's customFormTypes.ts JSON vocabulary. +/// The API field remains its generated opaque JSON container; unknown vocabulary fails closed. +@MainActor @Observable +final class MemberCustomForm { + struct Question: Identifiable { + let id: String + let label: String + let kind: String + let required: Bool + let options: [String] + } + let questions: [Question] + let unavailable: Bool + var text: [String: String] = [:] + var multiple: [String: Set] = [:] + var errors: [String: String] = [:] + init(data: Data?) { + guard let data else { questions = []; unavailable = false; return } + guard data.count <= 128_000, let raw = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else { questions = []; unavailable = true; return } + if raw is NSNull { questions = []; unavailable = false; return } + guard let rows = raw as? [[String: Any]], rows.count <= 100 else { questions = []; unavailable = true; return } + let known: Set = ["short_text", "paragraph", "number", "date", "single_choice", "multi_choice", "dropdown", "yes_no"] + var parsed: [Question] = []; var identifiers = Set() + for row in rows { + guard let id = row["id"] as? String, !id.isEmpty, id.count <= 200, identifiers.insert(id).inserted, + let label = row["label"] as? String, !label.isEmpty, label.count <= 1000, + let kind = row["type"] as? String, known.contains(kind), let required = row["required"] as? Bool, + let options = row["options"] as? [String], options.count <= 100, options.allSatisfy({ $0.count <= 1000 }) else { + questions = []; unavailable = true; return + } + parsed.append(Question(id: id, label: label, kind: kind, required: required, options: options)) + } + questions = parsed; unavailable = false + } + func validatedAnswers() -> [String: Any]? { + errors = [:] + guard !unavailable else { return nil } + var values: [String: Any] = [:] + for question in questions { + 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] = String(localized: "This question is required.") }; continue } + if question.kind == "multi_choice" { + 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] = String(localized: "Enter a valid number."); continue } + let number = NSDecimalNumber(decimal: decimal) + 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] = 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] = 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] = String(localized: "Choose one of the available options."); continue } + values[question.id] = value + } else { values[question.id] = value } + } + return errors.isEmpty ? values : nil + } +} + +struct MemberCustomFields: View { + @Bindable var form: MemberCustomForm + var body: some View { + if form.unavailable { + 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 ? String(localized: " (required)") : "")).brandFont(.headline) + field(question) + if let error = form.errors[question.id] { Text(error).foregroundStyle(Color.swDanger) } + } + } + } + } + private func binding(_ id: String) -> Binding { Binding(get: { form.text[id] ?? "" }, set: { form.text[id] = $0; form.errors[id] = nil }) } + @ViewBuilder private func field(_ question: MemberCustomForm.Question) -> some View { + switch question.kind { + case "yes_no": + Picker(question.label, selection: binding(question.id)) { Text("Choose an option").tag(""); Text("Yes").tag("true"); Text("No").tag("false") }.frame(minHeight: 48) + case "single_choice", "dropdown": + Picker(question.label, selection: binding(question.id)) { + Text("Choose an option").tag("") + ForEach(Array(Set(question.options)).sorted(), id: \.self) { Text($0).tag($0) } + }.frame(minHeight: 48) + case "multi_choice": + ForEach(Array(Set(question.options)).sorted(), id: \.self) { option in + Button { + var choices = form.multiple[question.id] ?? [] + if !choices.insert(option).inserted { choices.remove(option) } + form.multiple[question.id] = choices; form.errors[question.id] = nil + } label: { Label(option, systemImage: form.multiple[question.id]?.contains(option) == true ? "checkmark.square.fill" : "square").frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) } + .buttonStyle(.plain).accessibilityValue(form.multiple[question.id]?.contains(option) == true ? "Selected" : "Not selected") + } + case "date": + MemberQuestionDate(title: question.label, value: binding(question.id)) + case "paragraph": + TextField(question.label, text: binding(question.id), axis: .vertical).lineLimit(3...8).padding(12).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + default: + TextField(question.label, text: binding(question.id)).keyboardType(question.kind == "number" ? .numbersAndPunctuation : .default).frame(minHeight: 48) + .padding(.horizontal, 12).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + } + } +} + +private struct MemberQuestionDate: View { + let title: String + @Binding var value: String + @State private var date = Date() + private var formatter: DateFormatter { + let format = DateFormatter(); format.locale = Locale(identifier: "en_US_POSIX"); format.calendar = Calendar(identifier: .gregorian) + format.dateFormat = "yyyy-MM-dd"; format.isLenient = false; return format + } + var body: some View { + Toggle("Choose a date for \(title)", isOn: Binding(get: { !value.isEmpty }, set: { value = $0 ? formatter.string(from: date) : "" })).frame(minHeight: 48) + if !value.isEmpty { + DatePicker(title, selection: Binding(get: { formatter.date(from: value) ?? date }, set: { date = $0; value = formatter.string(from: $0) }), displayedComponents: .date) + .frame(minHeight: 48) + } + } +} diff --git a/SpaceWorks/MemberNavigation.swift b/SpaceWorks/MemberNavigation.swift new file mode 100644 index 0000000..51ef183 --- /dev/null +++ b/SpaceWorks/MemberNavigation.swift @@ -0,0 +1,29 @@ +import SwiftUI + +enum MemberRoute: Hashable { + 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. +@MainActor @Observable +final class MemberNavigation { + var selected = AppTab.home + var inventorySelection: Int? + private(set) var paths: [AppTab: [MemberRoute]] = [:] + func path(_ tab: AppTab) -> Binding<[MemberRoute]> { + Binding(get: { self.paths[tab] ?? [] }, set: { + self.paths[tab] = $0 + if tab == .inventory && $0.isEmpty { self.inventorySelection = nil } + }) + } + 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)] + } + func invalidate() { paths = [:]; inventorySelection = nil; selected = .home } +} diff --git a/SpaceWorks/MemberProductState.swift b/SpaceWorks/MemberProductState.swift new file mode 100644 index 0000000..5094580 --- /dev/null +++ b/SpaceWorks/MemberProductState.swift @@ -0,0 +1,21 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable final class MemberProductState { + let id: Int + private(set) var product: Components.Schemas.PublicProduct? + private(set) var error: String? + private(set) var retryAfter: Date? + private(set) var busy = false + private var active = true + private let fetch: @Sendable () async throws -> Components.Schemas.PublicProduct + init(id: Int, fetch: @escaping @Sendable () async throws -> Components.Schemas.PublicProduct) { self.id = id; self.fetch = fetch } + func load(now: Date = Date()) async { + guard active, !busy, (retryAfter ?? .distantPast) <= now else { return } + product = nil; error = nil; busy = true; defer { busy = false } + do { let value = try await fetch(); guard active else { return }; product = value; retryAfter = nil } + catch { guard active else { return }; self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } + } + func invalidate() { active = false; product = nil; error = nil; retryAfter = nil } +} diff --git a/SpaceWorks/MemberRoutes.swift b/SpaceWorks/MemberRoutes.swift new file mode 100644 index 0000000..af2ecc8 --- /dev/null +++ b/SpaceWorks/MemberRoutes.swift @@ -0,0 +1,104 @@ +import SwiftUI +import SpaceWorksAPI + +/// Destinations use IDs from confirmed lists. Never derive booking/registration IDs from titles. +struct MemberRouteView: View { + let route: MemberRoute + let member: MemberState + let actions: any MemberActionsRepository + let cart: RequestCartState + let drafts: MemberActionDrafts + let navigation: MemberNavigation + let capabilities: MemberCapabilities + let isDemo: Bool + 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 + #endif + var profileSaved: () async -> Void = {} + 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 }) { + SpaceBookingView(repository: actions, space: space, isDemo: isDemo, drafts: drafts, viewActivity: showActivity) { await member.loadBookings() } + } else { missing("Space unavailable", load: member.spacesLoad) { await member.loadSpaces() }.task { if member.spacesLoad == .idle { await member.loadSpaces() } } } + case .event(let token): + if let event = member.events.first(where: { $0.public_token == token }) { + EventDetailView(event: event, repository: actions, isDemo: isDemo, drafts: drafts, viewActivity: showActivity) { await member.loadActivity() } + } 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, insights: insights) } + 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.") } + 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.") } + case .preferences: + if capabilities.preferences { CommunicationPreferencesView(repository: actions, isDemo: isDemo, drafts: drafts, digestsEnabled: capabilities.digests) } + else { disabledFeature("Communication preferences aren't available in this makerspace.") } + } + } + private func disabledFeature(_ message: String) -> some View { + ScrollView { SWCard { Text(message).foregroundStyle(Color.swMuted) }.padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) }.background(Color.swBg) + } + private func showActivity() { navigation.path(.activity).wrappedValue = []; navigation.selected = .activity } + private func missing(_ title: String, load: MemberState.Load, retry: @escaping () async -> Void) -> some View { + ScrollView { + SWCard { + LoadFeedback(load: load, retry: retry) + 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) + } +} + +private struct BookingDestination: View { + let member: MemberState + let repository: any MemberActionsRepository + let token: String + let isDemo: Bool + @State private var initial: Components.Schemas.MemberBooking? + var body: some View { + Group { + if let initial { MemberBookingDetailView(booking: initial, repository: repository, isDemo: isDemo) { await member.loadBookings() } } + else { ProgressView("Loading bookingโ€ฆ") } + }.task { initial = member.bookings.first(where: { $0.public_token == token }) } + } +} + +private struct RegistrationDestination: View { + let member: MemberState + let repository: any MemberActionsRepository + let id: Int + let isDemo: Bool + let drafts: MemberActionDrafts + @State private var initial: Components.Schemas.MemberEventRegistrationActivity? + var body: some View { + Group { + if let initial { MemberRegistrationView(registration: initial, repository: repository, isDemo: isDemo, drafts: drafts) { await member.loadActivity() } } + else { ProgressView("Loading registrationโ€ฆ") } + }.task { initial = member.activity?.event_registrations?.first(where: { $0.registration_id == id }) } + } +} diff --git a/SpaceWorks/MemberSettingsState.swift b/SpaceWorks/MemberSettingsState.swift new file mode 100644 index 0000000..7a4db72 --- /dev/null +++ b/SpaceWorks/MemberSettingsState.swift @@ -0,0 +1,81 @@ +import Foundation +import Observation +import SpaceWorksAPI + +@MainActor @Observable +final class ProfileEditorState { + let repository: any MemberActionsRepository + var displayName = "" + var firstName = "" + var lastName = "" + var phone = "" + private(set) var original: Components.Schemas.AccountProfile? + private(set) var busy = false + private(set) var loaded = false + private(set) var error: String? + private(set) var notice: String? + private(set) var retryAfter: Date? + private var active = true + init(repository: any MemberActionsRepository) { self.repository = repository } + var dirty: Bool { + guard let original else { return false } + return displayName != (original.display_name ?? "") || firstName != (original.first_name ?? "") || lastName != (original.last_name ?? "") || phone != (original.phone ?? "") + } + func load(now: Date = Date()) async { + guard active, !busy, (retryAfter ?? .distantPast) <= now else { return }; busy = true; error = nil; defer { busy = false } + do { let value = try await repository.profile(); guard active else { return }; original = value; discard(); loaded = true } + catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } + func discard() { + guard let original else { return } + displayName = original.display_name ?? ""; firstName = original.first_name ?? ""; lastName = original.last_name ?? ""; phone = original.phone ?? "" + notice = nil; error = nil + } + func save(now: Date = Date()) async { + guard active, !busy, loaded, dirty, (retryAfter ?? .distantPast) <= now else { 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 = 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 } +} + +@MainActor @Observable +final class CommunicationPreferencesState { + var digestsAllowed = true + let repository: any MemberActionsRepository + private(set) var cells: [Components.Schemas.PreferenceCell] = [] + private(set) var loaded = false + private(set) var busy = false + private(set) var error: String? + private(set) var retryAfter: Date? + private var active = true + init(repository: any MemberActionsRepository) { self.repository = repository } + func load(now: Date = Date()) async { + guard active, !busy, (retryAfter ?? .distantPast) <= now else { return }; busy = true; error = nil; defer { busy = false } + do { let value = try await repository.preferences(); guard active else { return }; cells = value.cells; loaded = true } + catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } + 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 = 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 { + guard active, !busy, digestsAllowed, cell.available, cell.enabled, cell.channel.rawValue == "email", cell.daily_digest != nil, + (retryAfter ?? .distantPast) <= now else { return } + await write(.init(feature: cell.feature, channel: cell.channel, enabled: cell.enabled, daily_digest: enabled)) + } + private func write(_ input: Components.Schemas.PatchedPreferenceWrite) async { + busy = true; error = nil; defer { busy = false } + do { let value = try await repository.updatePreference(input); guard active else { return }; cells = value.cells } + catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } + func invalidate() { active = false; cells = []; loaded = false; error = nil } +} diff --git a/SpaceWorks/MemberSettingsViews.swift b/SpaceWorks/MemberSettingsViews.swift new file mode 100644 index 0000000..0011322 --- /dev/null +++ b/SpaceWorks/MemberSettingsViews.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct ProfileEditorView: View { + @State private var state: ProfileEditorState + let isDemo: Bool + 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 + } + var body: some View { + ScrollView { + SWCard { + 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").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 + Button(state.busy ? "Savingโ€ฆ" : "Save changes") { Task { await state.save(); if !state.dirty && state.notice != nil { await saved() } } } + .buttonStyle(SWPrimary()).disabled(state.busy || !state.dirty || (state.retryAfter ?? .distantPast) > tick.date) + } + Button("Discard changes") { wantsExit = false; discardConfirmation = true }.frame(minHeight: 48).disabled(state.busy || !state.dirty) + } else if let error = state.error { + SWError(message: error); Button("Try again") { Task { await state.load() } }.frame(minHeight: 48) + } else { ProgressView("Loading profileโ€ฆ") } + }.disabled(state.busy).padding(24).frame(maxWidth: 640).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Profile details").scrollDismissesKeyboard(.interactively) + .navigationBarBackButtonHidden(state.dirty || state.busy).interactiveDismissDisabled(state.dirty || state.busy) + .toolbar { + 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() } } + .confirmationDialog("Discard your changes?", isPresented: $discardConfirmation, titleVisibility: .visible) { + Button("Discard changes", role: .destructive) { state.discard(); if wantsExit { dismiss() } } + Button("Keep editing", role: .cancel) { wantsExit = false } + } message: { Text("Your saved profile will stay as it was.") } + } + private func field(_ label: String, value: Binding, identifier: String, content: UITextContentType) -> some View { + VStack(alignment: .leading, spacing: 8) { + 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) + .padding(14).background(Color.swBg, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.swOutline)).accessibilityIdentifier(identifier) + } + } +} + +struct CommunicationPreferencesView: View { + @State private var state: CommunicationPreferencesState + let isDemo: Bool + let digestsEnabled: Bool + init(repository: any MemberActionsRepository, isDemo: Bool, drafts: MemberActionDrafts? = nil, digestsEnabled: Bool = true) { _state = State(initialValue: drafts?.preferences ?? CommunicationPreferencesState(repository: repository)); self.isDemo = isDemo; self.digestsEnabled = digestsEnabled } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + 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").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) } + else { ProgressView("Loading preferencesโ€ฆ") } + } 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)).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)") + .frame(minHeight: 48).disabled(state.busy || (!cell.available && !cell.enabled) || (state.retryAfter ?? .distantPast) > tick.date) + if digestsEnabled, cell.channel.rawValue == "email", let digest = cell.daily_digest { + Toggle("Daily email digest", isOn: Binding(get: { digest }, set: { value in Task { await state.digest(cell, enabled: value) } })) + .frame(minHeight: 48).disabled(state.busy || !cell.available || !cell.enabled || (state.retryAfter ?? .distantPast) > tick.date) + } + } + 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: ", "))").brandFont(.caption).foregroundStyle(Color.swMuted) + } + } + } + }.padding(24).frame(maxWidth: 800, alignment: .leading).frame(maxWidth: .infinity) + }.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 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 new file mode 100644 index 0000000..671047d --- /dev/null +++ b/SpaceWorks/MemberState.swift @@ -0,0 +1,229 @@ +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 categoriesLoad = 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 let guidanceChanged: ((Bool) -> Void)? + 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 + private(set) var retryDeadlines: [String: Date] = [:] + private var unreadLoading = false + private let now: () -> Date + + 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.guidanceChanged = guidanceChanged + self.guidanceKey = context.map { "guidance.member-v1." + $0 } + 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) }; 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 = [:] + retryDeadlines = [:] + } + func deadline(_ role: String) -> Date? { retryDeadlines[role] } + func canLoad(_ role: String) -> Bool { active && (retryDeadlines[role] ?? .distantPast) <= now() } + private func recordFailure(_ error: Error, role: String) { + if let deadline = APIError.retryDeadline(error), deadline > now() { + retryDeadlines[role] = max(retryDeadlines[role] ?? .distantPast, deadline) + } + } + func loadHome() async { + guard active else { return } + 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 { + guard canLoad("inventory") else { return } + 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 { await loadCategories() } + } catch { + guard active, generation == inventoryGeneration else { return } + recordFailure(error, role: "inventory") + if discard(error) { products = []; updated["inventory"] = nil } + inventoryLoad = .failed(APIError.display(error)) + } + } + func loadCategories() async { + guard canLoad("categories"), categoriesLoad != .loading else { return } + categoriesLoad = .loading + do { let result = try await repository.categories(); guard active else { return }; categories = result; categoriesLoad = .ready } + catch { guard active else { return }; recordFailure(error, role: "categories"); categoriesLoad = .failed(APIError.display(error)) } + } + func loadActivity() async { + guard canLoad("activity"), activityLoad != .loading else { return } + 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 }; recordFailure(error, role: "activity"); if discard(error) { activity = nil; updated["activity"] = nil }; activityLoad = .failed(APIError.display(error)) } + } + func loadBookings(more: Bool = false) async { + guard canLoad("bookings") else { return } + 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 } + recordFailure(error, role: "bookings") + if discard(error) { bookings = []; updated["bookings"] = nil }; bookingsLoad = .failed(APIError.display(error)) + } + } + func loadEvents() async { + guard canLoad("events"), eventsLoad != .loading else { return } + 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 }; recordFailure(error, role: "events"); if discard(error) { events = []; updated["events"] = nil }; eventsLoad = .failed(APIError.display(error)) } + } + func loadSpaces() async { + guard canLoad("spaces"), spacesLoad != .loading else { return } + spacesLoad = .loading + do { let result = try await repository.spaces(); guard active else { return }; spaces = result; spacesLoad = .ready } + catch { guard active else { return }; recordFailure(error, role: "spaces"); spacesLoad = .failed(APIError.display(error)) } + } + func loadCard() async { + // 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 } + catch { guard active, generation == cardGeneration else { return }; recordFailure(error, role: "card"); card = nil; cardLoad = .failed(APIError.display(error)) } + } + func loadInbox(more: Bool = false) async { + guard canLoad("inbox") else { return } + 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() + await loadUnreadCount() + } catch { + guard active, generation == inboxGeneration else { return } + recordFailure(error, role: "inbox") + if discard(error) { messages = []; unreadCount = nil; updated["inbox"] = nil } + inboxLoad = .failed(APIError.display(error)) + } + } + func loadUnreadCount() async { + guard canLoad("unread"), !unreadLoading else { return } + unreadLoading = true; defer { unreadLoading = false } + do { let count = try await repository.unreadCount(); guard active else { return }; unreadCount = count } + catch { guard active else { return }; recordFailure(error, role: "unread"); if discard(error) { unreadCount = nil } } + } + func markRead(_ id: Int) async { + guard canLoad("inbox"), !inboxMutating else { return }; inboxMutating = true; defer { inboxMutating = false } + do { try await repository.markRead(id); guard active else { return }; await loadInbox() } + catch { if active { recordFailure(error, role: "inbox"); inboxLoad = .failed(APIError.display(error)) } } + } + func markAllRead() async { + guard canLoad("inbox"), !inboxMutating else { return }; inboxMutating = true; defer { inboxMutating = false } + do { try await repository.markAllRead(); guard active else { return }; await loadInbox() } + catch { if active { recordFailure(error, role: "inbox"); 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: String(localized: "The server returned an invalid next page.")) + } + return page + } +} diff --git a/SpaceWorks/MemberTicketState.swift b/SpaceWorks/MemberTicketState.swift new file mode 100644 index 0000000..dc788da --- /dev/null +++ b/SpaceWorks/MemberTicketState.swift @@ -0,0 +1,36 @@ +import Foundation +import Observation +import UIKit +import SpaceWorksAPI + +@MainActor @Observable final class MemberTicketState { + let registration: Components.Schemas.MemberEventRegistrationActivity + let repository: any MemberActionsRepository + private(set) var image: UIImage? + private(set) var error: String? + private(set) var busy = false + private(set) var cancelled = false + private(set) var retryAfter: Date? + private var active = true + private var generation = 0 + private var cancelling = false + init(registration: Components.Schemas.MemberEventRegistrationActivity, repository: any MemberActionsRepository) { self.registration = registration; self.repository = repository } + func load(now: Date = Date()) async { + guard active, !cancelled, registration.status == "registered", !cancelling, (retryAfter ?? .distantPast) <= now else { return } + image = nil; error = nil; generation += 1; let expected = generation + busy = true; defer { if expected == generation { busy = false } } + 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 = 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) } } + } + func cancel(now: Date = Date()) async { + guard active, !busy, !cancelled, registration.can_cancel, (retryAfter ?? .distantPast) <= now else { return } + generation += 1; cancelling = true; busy = true; image = nil; error = nil; defer { busy = false; cancelling = false } + do { _ = try await repository.cancelEvent(registration: registration.registration_id); guard active else { return }; cancelled = true; retryAfter = nil } + catch { if active { self.error = APIError.display(error); retryAfter = APIError.retryDeadline(error) } } + } + func invalidate() { active = false; generation += 1; image = nil; error = nil; retryAfter = nil } +} diff --git a/SpaceWorks/MemberViews.swift b/SpaceWorks/MemberViews.swift new file mode 100644 index 0000000..c3204ba --- /dev/null +++ b/SpaceWorks/MemberViews.swift @@ -0,0 +1,440 @@ +import SwiftUI +import SpaceWorksAPI +import ImageIO + +struct MemberContent: View { + @Bindable var state: MemberState + let tab: AppTab + let name: String + var selectTab: (AppTab) -> Void = { _ in } + var actions: (any MemberActionsRepository)? = nil + var cart: RequestCartState? = nil + var navigation: MemberNavigation? = nil + var drafts: MemberActionDrafts? = nil + 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 + var body: some View { + Group { + switch tab { + case .inventory: InventoryList(state: state, cart: cart, navigation: navigation, drafts: drafts) + case .inbox: MemberInbox(state: state, openLink: openLink) + default: + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if tab == .home { + Text("Welcome back, \(name).").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text("Your makerspace, at a glance.").foregroundStyle(Color.swMuted) + if state.showGuidance { + SWCard { + 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) } + else { Button("View profile") { selectTab(.profile) }.frame(minHeight: 48) } + } + } + } + LazyVGrid(columns: textSize.isAccessibilitySize ? [GridItem(.flexible(), alignment: .topLeading)] : [GridItem(.adaptive(minimum: 320), spacing: 24, alignment: .topLeading)], alignment: .leading, spacing: 24) { + loans + if tab == .activity { + if paymentsAvailable { SWCard { NavigationLink("Payments", value: MemberRoute.payments).frame(minHeight: 48) } } + requests; registrations + } + bookings + events + if tab == .home { + dues + SWCard { + 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) + } + notices + } + } + if tab == .home, let insights { UsageSection(state: insights) } + }.padding(24).frame(maxWidth: 1040, alignment: .leading).frame(maxWidth: .infinity) + }.refreshable { await state.loadHome(); if tab == .home { await insights?.refreshUsage() } } + } + }.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) } + .sheet(isPresented: Binding(get: { renewal != nil }, set: { if !$0 { renewal = nil } })) { + if let renewal, let actions { RenewLoanView(loan: renewal, repository: actions, isDemo: state.repository.isDemo) { await state.loadActivity() } } + } + } + private var loans: some View { + SWCard { + 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).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.")).brandFont(.caption).foregroundStyle(Color.swMuted) } + } + }.padding(.vertical, 8) + } + } + } + } + private var requests: some View { + SWCard { + 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)).brandFont(.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").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)).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) + } + 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").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)).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) + } + } + } + private var registrations: some View { + SWCard { + 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)).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) + } + } + } else if state.activityLoad == .ready { Text("Nothing here yet.").foregroundStyle(Color.swMuted) } + } + } + private var notices: some View { + SWCard { + 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).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").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() } + } 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] ?? "")").brandFont(.title2, weight: .bold).monospacedDigit() } + } else { Text("Dues are unavailable. Try again later.").foregroundStyle(Color.swMuted) } + } + } + } +} + +struct LoadFeedback: View { + let load: MemberState.Load + var lastUpdated: Date? + var retryAfter: 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))").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) + } + } + case .ready: EmptyView() + } + } +} + +struct InventoryList: View { + @Bindable var state: MemberState + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.dynamicTypeSize) private var textSize + var cart: RequestCartState? = nil + var navigation: MemberNavigation? = nil + var drafts: MemberActionDrafts? = nil + @State private var localSelection: Int? + private var selectedProduct: Int? { navigation?.inventorySelection ?? localSelection } + 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, cart: cart, drafts: drafts) { await state.loadActivity() }.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 ?? 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() } + } + 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").brandFont(.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 { if let navigation { navigation.inventorySelection = product.id } else { localSelection = product.id } } label: { productRow(product) } + .buttonStyle(.plain).accessibilityIdentifier("product-\(product.id)") + .accessibilityAddTraits(selectedProduct == product.id ? .isSelected : []) + } else { + NavigationLink(value: MemberRoute.product(product.id)) { 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 { + if textSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 8) { + 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) + } 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).brandFont(.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) + } + } + } + } +} + +private func availability(_ product: Components.Schemas.PublicProduct) -> String { + 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 { + 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 + var cart: RequestCartState? + var completed: () async -> Void + @State private var state: MemberProductState + @State private var reviewCart = false + @State private var added = false + init(repository: any MemberRepository, id: Int, cart: RequestCartState? = nil, drafts: MemberActionDrafts? = nil, completed: @escaping () async -> Void = {}) { + self.repository = repository; self.id = id; self.cart = cart; self.completed = completed + _state = State(initialValue: drafts?.product(id, repository: repository) ?? MemberProductState(id: id) { try await repository.product(id) }) + } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let product = state.product { + 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) + if added { Label("Added to your request", systemImage: "checkmark.circle").foregroundStyle(Color.swAccentInk) } + 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").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โ€ฆ") } + }.padding(24).frame(maxWidth: 800, alignment: .leading).frame(maxWidth: .infinity) + }.background(Color.swBg).navigationTitle("Item details").task { await state.load() } + .sheet(isPresented: $reviewCart) { + if let cart { RequestCartView(state: cart, isDemo: repository.isDemo, completed: completed) } + } + } +} + +struct MemberInbox: View { + @Bindable var state: MemberState + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var paymentsAvailable = false + var openLink: ((String) -> Void)? = nil + 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"], retryAfter: state.deadline("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 { + 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 { + 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) } + } + } + 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").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).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") + } 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/MobileLink.swift b/SpaceWorks/MobileLink.swift new file mode 100644 index 0000000..a1df6bf --- /dev/null +++ b/SpaceWorks/MobileLink.swift @@ -0,0 +1,134 @@ +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 + } + /// 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? + 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 + 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 == 4, pieces[2] == "admin", pieces[3] == "notifications" { return Legacy(slug: pieces[1], staffInbox: true) } + 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) } + 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..886bcd2 --- /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: 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)) } + } + 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 ?? String(localized: "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..de1a45f --- /dev/null +++ b/SpaceWorks/MobileLinkRouter.swift @@ -0,0 +1,92 @@ +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 = 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, 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 + } + 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: 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) + } 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: 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 } + 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/NativeAppearance.swift b/SpaceWorks/NativeAppearance.swift index 6f3e800..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 { @@ -30,12 +30,23 @@ 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 } 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/NativeAttestor.swift b/SpaceWorks/NativeAttestor.swift new file mode 100644 index 0000000..5905ba9 --- /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: 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)))) + // 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..02448a5 --- /dev/null +++ b/SpaceWorks/NativeSignInViews.swift @@ -0,0 +1,148 @@ +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) + .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) } + 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/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/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..97140a0 --- /dev/null +++ b/SpaceWorks/NotificationSettingsView.swift @@ -0,0 +1,54 @@ +import SwiftUI +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(contentPadding: dynamicTypeSize.isAccessibilitySize ? 12 : 24) { + 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) + } else if state.permissionDenied { + 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) + 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(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/SpaceWorks/PasswordEntry.swift b/SpaceWorks/PasswordEntry.swift new file mode 100644 index 0000000..66ca0b7 --- /dev/null +++ b/SpaceWorks/PasswordEntry.swift @@ -0,0 +1,114 @@ +import SwiftUI +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 + var identifier: String + var label = String(localized: "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 = BrandTypography.uiFont(traits: field.traitCollection, boldText: legibilityWeight == .bold) + field.textColor = UIColor(named: "ink") + 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 + 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 + 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 + } + } +} + +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 + 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).brandFont(.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 ? 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 new file mode 100644 index 0000000..58706ac --- /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: String(localized: "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: String(localized: "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..f1343de --- /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: 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: String(localized: "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..d525d5b --- /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: String(localized: "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.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) + 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: String(localized: "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..12b40be --- /dev/null +++ b/SpaceWorks/PaymentsViews.swift @@ -0,0 +1,309 @@ +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").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) + 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").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).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)").brandFont(.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").brandFont(.largeTitle, weight: .bold).accessibilityAddTraits(.isHeader) + Text(row.subject_label).brandFont(.title2, weight: .bold) + SWCard { + 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)").brandFont(.subheadline).foregroundStyle(Color.swMuted) + } + if let settlement = row.settlement?.value1 { + SWCard { + 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) } + } + } + 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").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)).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) } + } + } + } + NavigationLink("Receipts and credit notes") { PaymentDocumentsView(state: state, paymentID: paymentID) } + .frame(minHeight: 48) + } else { + 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) + } + }.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 + .sheet(isPresented: $demoChoice) { + DemoPaymentOutcomeSheet(state: state, paymentID: paymentID) + } + #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) } + } + } + } +} + +#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").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) + 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).brandFont(.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? + @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").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โ€ฆ") } + 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)").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) + .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 } + // 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 { + 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/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/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/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/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/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/Platform/NativeFileTransfer.swift b/SpaceWorks/Platform/NativeFileTransfer.swift new file mode 100644 index 0000000..9bf5305 --- /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 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.") + } + } +} + +/// 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/NativeIdentity.swift b/SpaceWorks/Platform/NativeIdentity.swift new file mode 100644 index 0000000..7c96f57 --- /dev/null +++ b/SpaceWorks/Platform/NativeIdentity.swift @@ -0,0 +1,131 @@ +import AuthenticationServices +import CryptoKit +import GoogleSignIn +import SpaceWorksAPI +import UIKit + +/// 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, 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 + // 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() } + 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 { + APIError(status: 0, code: "native_provider_unconfigured", detail: String(localized: "Native sign-in is not configured for this build. Contact your makerspace.")) + } +} + +/// 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 anchor: ASPresentationAnchor? + private var controller: ASAuthorizationController? + 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] + // 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 + 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 + } + // 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)) } + } + private func finish(_ result: Result) { + let pending = continuation; continuation = nil; controller = nil; anchor = nil + pending?.resume(with: result) + } +} + +/// 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 { + 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 { + 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) } + } + 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 new file mode 100644 index 0000000..c24d168 --- /dev/null +++ b/SpaceWorks/Platform/NativeNotifications.swift @@ -0,0 +1,144 @@ +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? + + /// 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 + 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? + private(set) var pendingHint: NotificationHint? + var onTokenChanged: ((String) -> Void)? + var onTap: ((NotificationHint) -> Void)? { + didSet { deliverPendingHint() } + } + var onForegroundHint: ((NotificationHint) -> Void)? + + 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 = String(localized: "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 = 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) { + pendingHint = hint + 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 + 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 + onForegroundHint = 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.receive(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 + if let hint { NativeNotifications.shared.receivedInForeground(hint) } + // Backend payloads are hints, not displayable or authoritative content. + completionHandler([]) + } + } +} diff --git a/SpaceWorks/Platform/NativeOIDC.swift b/SpaceWorks/Platform/NativeOIDC.swift new file mode 100644 index 0000000..2e64744 --- /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 = 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)) + 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/Platform/NativeOrientationDiagnostics.swift b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift new file mode 100644 index 0000000..e1f7627 --- /dev/null +++ b/SpaceWorks/Platform/NativeOrientationDiagnostics.swift @@ -0,0 +1,198 @@ +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 var settledRevision = 0 + private var settledTasks: [Task] = [] + 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 { + 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) + } + scheduleRead(reason: "attached") + } + override func layoutSubviews() { + super.layoutSubviews() + scheduleRead(reason: "layout") + } + func stop() { + cancelSettledReads() + 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 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. + 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, + "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:"), !reason.hasPrefix("settled-orientation:"), 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.notice("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/Platform/NativePaymentCallback.swift b/SpaceWorks/Platform/NativePaymentCallback.swift new file mode 100644 index 0000000..a3e5ea9 --- /dev/null +++ b/SpaceWorks/Platform/NativePaymentCallback.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Old SDK callbacks may arrive after logout/reset. They cannot complete a later payment attempt. +@MainActor final class NativePaymentCallback { + private var active: UUID? + private var completion: ((NativePaymentOutcome) -> Void)? + var isActive: Bool { active != nil } + + func begin(_ completion: @escaping (NativePaymentOutcome) -> Void) -> UUID? { + guard active == nil else { return nil } + let id = UUID() + active = id + self.completion = completion + return id + } + + @discardableResult func finish(_ result: NativePaymentOutcome, id: UUID, beforeCallback: () -> Void = {}) -> Bool { + guard active == id else { return false } + let handler = completion + active = nil + completion = nil + beforeCallback() + handler?(result) + return true + } + + func cancel() { + if let id = active { finish(.cancelled, id: id) } + } +} diff --git a/SpaceWorks/Platform/NativePayments.swift b/SpaceWorks/Platform/NativePayments.swift new file mode 100644 index 0000000..bff4c96 --- /dev/null +++ b/SpaceWorks/Platform/NativePayments.swift @@ -0,0 +1,200 @@ +import SwiftUI +import UIKit +#if canImport(StripePaymentSheet) +import StripePaymentSheet +import StripePayments +import StripeCore +#endif +#if canImport(Razorpay) +import Razorpay +import RazorpayCore +#endif + +enum NativePaymentOutcome: Equatable { + case awaitingServerConfirmation + case cancelled + case failed(String) + case unavailable(String) +} + +/// SDK parameters only: these are not transport models. The generated API gateway selects the vendor. +enum NativePaymentInput { + static func stripeIsValid(key: String, secret: String, customer: String?, ephemeralKey: String?) -> Bool { + let keyValid = key.hasPrefix("pk_test_") || key.hasPrefix("pk_live_") + let secretValid = secret.hasPrefix("pi_") && secret.contains("_secret_") + let customerValid = (customer == nil && ephemeralKey == nil) || + (customer?.hasPrefix("cus_") == true && ephemeralKey?.hasPrefix("ek_") == true) + return keyValid && secretValid && customerValid + } + + static func razorpayIsValid(key: String, order: String, amount: Int64, currency: String) -> Bool { + (key.hasPrefix("rzp_test_") || key.hasPrefix("rzp_live_")) && order.hasPrefix("order_") && + amount > 0 && currency.count == 3 && currency.unicodeScalars.allSatisfy { (65...90).contains(Int($0.value)) } + } +} + +@MainActor final class NativePayments: NSObject { + private let callback = NativePaymentCallback() + private var resetting = false + private weak var presenter: UIViewController? + #if canImport(StripePaymentSheet) + private var stripe: PaymentSheet? + #endif + #if canImport(Razorpay) + private var razorpay: Razorpay.RazorpayCheckout? + private var razorpayCallbacks: RazorpayCallbacks? + #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) { + guard !isDemo else { completion(.unavailable("Demo mode does not open a payment provider.")); return } + guard NativePaymentInput.stripeIsValid(key: publishableKey, secret: clientSecret, customer: customerID, ephemeralKey: ephemeralKey) else { + completion(.unavailable("Payment setup is unavailable. Contact your makerspace.")); return + } + #if canImport(StripePaymentSheet) + guard let attempt = begin(from: controller, completion: completion) else { return } + var configuration = PaymentSheet.Configuration() + configuration.apiClient = STPAPIClient(publishableKey: publishableKey) + configuration.merchantDisplayName = Brand.appName + configuration.returnURL = returnURL?.absoluteString + if let customerID, let ephemeralKey { + configuration.customer = .init(id: customerID, ephemeralKeySecret: ephemeralKey) + } + configuration.appearance = Self.stripeAppearance() + let sheet = PaymentSheet(paymentIntentClientSecret: clientSecret, configuration: configuration) + stripe = sheet + sheet.present(from: controller) { [weak self] result in + Task { @MainActor in + switch result { + case .completed: self?.finish(.awaitingServerConfirmation, id: attempt) + case .canceled: self?.finish(.cancelled, id: attempt) + case .failed: self?.finish(.failed("Payment could not be completed. Check its status before trying again."), id: attempt) + } + } + } + #else + completion(.unavailable("The payment SDK is not configured in this build.")) + #endif + } + + /// 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 { + completion(.unavailable("Payment setup is unavailable. Contact your makerspace.")); return + } + #if canImport(Razorpay) + guard let attempt = begin(from: controller, completion: completion) else { return } + let delegate = RazorpayCallbacks { [weak self] result in self?.finish(result, id: attempt) } + razorpayCallbacks = delegate + razorpay = Razorpay.RazorpayCheckout.initWithKey(keyID, andDelegateWithData: delegate) + var options: [AnyHashable: Any] = ["key": keyID, "order_id": orderID, "amount": amountMinor, + "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 + completion(.unavailable("The payment SDK is not configured in this build.")) + #endif + } + + private func begin(from controller: UIViewController, completion: @escaping (NativePaymentOutcome) -> Void) -> UUID? { + guard !resetting, !hasInFlightPayment, controller.viewIfLoaded?.window != nil, controller.presentedViewController == nil else { + completion(.unavailable("Payment is not ready to open. Close any open sheet and try again.")); return nil + } + presenter = controller + return callback.begin(completion) + } + + private func finish(_ outcome: NativePaymentOutcome, id: UUID) { + callback.finish(outcome, id: id) { + presenter = nil + #if canImport(StripePaymentSheet) + stripe = nil + #endif + #if canImport(Razorpay) + razorpay = nil + razorpayCallbacks = nil + #endif + } + // Even SDK success only starts server reconciliation. No callback can mark a payment paid. + } + + func reset() { + resetting = true + defer { resetting = false } + if hasInFlightPayment { presenter?.presentedViewController?.dismiss(animated: false) } + presenter = nil + #if canImport(StripePaymentSheet) + stripe = nil + PaymentSheet.resetCustomer() + #endif + #if canImport(Razorpay) + razorpay = nil + razorpayCallbacks = nil + Razorpay.RazorpayCheckout.clearUserData() + #endif + callback.cancel() + } + + static func handleReturnURL(_ url: URL) -> Bool { + #if canImport(StripePaymentSheet) + return StripeAPI.handleURLCallback(with: url) + #else + return false + #endif + } + + private func accentHex(for traits: UITraitCollection) -> String? { + let color = UIColor(Color.swAccent).resolvedColor(with: traits) + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + guard color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil } + return "#" + String(format: "%02X%02X%02X", Int((red * 255).rounded()), Int((green * 255).rounded()), Int((blue * 255).rounded())) + } +} + +#if canImport(Razorpay) +@MainActor private final class RazorpayCallbacks: NSObject, RazorpayPaymentCompletionProtocolWithData { + let complete: (NativePaymentOutcome) -> Void + init(complete: @escaping (NativePaymentOutcome) -> Void) { self.complete = complete } + nonisolated func onPaymentSuccess(_ payment_id: String, andData response: [AnyHashable: Any]?) { + Task { @MainActor [weak self] in self?.complete(.awaitingServerConfirmation) } + } + nonisolated func onPaymentError(_ code: Int32, description str: String, andData response: [AnyHashable: Any]?) { + Task { @MainActor [weak self] in + self?.complete(code == 2 ? .cancelled : .failed("Payment could not be completed. Check its status before trying again.")) + } + } +} +#endif diff --git a/SpaceWorks/Platform/NativePrivacyShield.swift b/SpaceWorks/Platform/NativePrivacyShield.swift new file mode 100644 index 0000000..eeafdc1 --- /dev/null +++ b/SpaceWorks/Platform/NativePrivacyShield.swift @@ -0,0 +1,92 @@ +import SwiftUI +import UIKit + +/// A separate opaque window covers every app sheet during inactive/background snapshots. +/// It never becomes key, so returning to the app preserves the original responder and draft. +@MainActor final class NativePrivacyShieldController: NSObject { + private weak var host: UIWindow? + private(set) var shield: UIWindow? + private var observing = false + + func attach(to window: UIWindow?) { + guard host !== window else { return } + hide() + host = window + if window != nil && !observing { + observing = true + let center = NotificationCenter.default + center.addObserver(self, selector: #selector(willResign(_:)), name: UIApplication.willResignActiveNotification, object: nil) + center.addObserver(self, selector: #selector(sceneWillDeactivate(_:)), name: UIScene.willDeactivateNotification, object: nil) + center.addObserver(self, selector: #selector(didActivate(_:)), name: UIScene.didActivateNotification, object: nil) + center.addObserver(self, selector: #selector(applicationDidActivate(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) + } + if let scene = window?.windowScene, scene.activationState != .foregroundActive { show() } + } + + func detach() { + hide() + host = nil + NotificationCenter.default.removeObserver(self) + observing = false + } + + @objc private func willResign(_ notification: Notification) { show() } + @objc private func sceneWillDeactivate(_ notification: Notification) { + guard let scene = notification.object as? UIWindowScene, scene === host?.windowScene else { return } + show() + } + @objc private func didActivate(_ notification: Notification) { + guard let scene = notification.object as? UIWindowScene, scene === host?.windowScene else { return } + hide() + } + @objc private func applicationDidActivate(_ notification: Notification) { + if host?.windowScene?.activationState == .foregroundActive { hide() } + } + + private func show() { + guard shield == nil, let host, let scene = host.windowScene else { return } + let cover = UIWindow(windowScene: scene) + cover.frame = host.frame + cover.windowLevel = .init(rawValue: UIWindow.Level.alert.rawValue + 1) + let controller = UIViewController() + controller.view.backgroundColor = UIColor(named: "bg") ?? .systemBackground + controller.view.isOpaque = true + controller.view.accessibilityIdentifier = "private-app-snapshot" + controller.view.accessibilityLabel = Brand.appName + controller.view.accessibilityViewIsModal = true + cover.rootViewController = controller + cover.isOpaque = true + cover.isHidden = false + // Do not call makeKeyAndVisible: the app's existing key window keeps keyboard ownership. + cover.layoutIfNeeded() + shield = cover + } + + private func hide() { + shield?.isHidden = true + shield?.rootViewController = nil + shield = nil + } +} + +private struct NativePrivacyShield: UIViewRepresentable { + func makeCoordinator() -> NativePrivacyShieldController { NativePrivacyShieldController() } + func makeUIView(context: Context) -> PrivacyHostView { + let view = PrivacyHostView() + view.controller = context.coordinator + view.isUserInteractionEnabled = false + view.isAccessibilityElement = false + return view + } + func updateUIView(_ uiView: PrivacyHostView, context: Context) { context.coordinator.attach(to: uiView.window) } + static func dismantleUIView(_ uiView: PrivacyHostView, coordinator: NativePrivacyShieldController) { coordinator.detach() } +} + +private final class PrivacyHostView: UIView { + weak var controller: NativePrivacyShieldController? + override func didMoveToWindow() { super.didMoveToWindow(); controller?.attach(to: window) } +} + +extension View { + func nativePrivacyShield() -> some View { background(NativePrivacyShield().frame(width: 0, height: 0).accessibilityHidden(true)) } +} diff --git a/SpaceWorks/Platform/NativeQRSVG.swift b/SpaceWorks/Platform/NativeQRSVG.swift new file mode 100644 index 0000000..5ce2ad2 --- /dev/null +++ b/SpaceWorks/Platform/NativeQRSVG.swift @@ -0,0 +1,130 @@ +import Foundation +import UIKit + +/// A deliberately small native renderer for server-issued QR SVGs, not a browser/SVG engine. +/// No scripts, entities, stylesheets, fonts, links, images or external resources are accepted. +enum NativeQRSVG { + static func image(_ data: Data) -> UIImage? { + guard data.count <= 512 * 1024, let text = String(data: data, encoding: .utf8), + !text.localizedCaseInsensitiveContains("= 1, values[3] >= 1 { + bounds = CGRect(x: values[0], y: values[1], width: values[2], height: values[3]) + } else if let width = number(attributes["width"]), let height = number(attributes["height"]), width >= 1, height >= 1 { + bounds = CGRect(x: 0, y: 0, width: width, height: height) + } else { refuse(parser) } + case "path": + guard bounds != nil, let data = attributes["d"], let path = Self.path(data), let black = ink(attributes["stroke"] ?? attributes["fill"] ?? "black") else { refuse(parser); return } + let stroke: CGFloat? + if attributes["stroke"] != nil { + guard let width = number(attributes["stroke-width"] ?? "1"), width > 0, width <= 100 else { refuse(parser); return } + stroke = width + } else { stroke = nil } + shapes.append(Shape(path: path, stroke: stroke, black: black, fill: attributes["fill"] != "none")) + case "rect": + guard bounds != nil, let black = ink(attributes["fill"] ?? "black"), let width = number(attributes["width"]), let height = number(attributes["height"]), width > 0, height > 0, + let x = number(attributes["x"] ?? "0"), let y = number(attributes["y"] ?? "0") else { refuse(parser); return } + shapes.append(Shape(path: CGPath(rect: CGRect(x: x, y: y, width: width, height: height), transform: nil), stroke: nil, black: black, fill: true)) + case "g": if attributes["fill"] != nil || attributes["stroke"] != nil { refuse(parser) } + case "title", "desc": break + default: refuse(parser) + } + } + private func ink(_ source: String) -> Bool? { + let source = source.lowercased() + if source == "black" { return true } + if source == "white" { return false } + guard source.hasPrefix("#"), [4, 7].contains(source.count) else { return nil } + let digits = Set(source.dropFirst()) + if digits == ["0"] { return true } + if digits == ["f"] { return false } + return nil + } + private func number(_ text: String?) -> CGFloat? { + guard let text, let value = Double(text), value.isFinite, abs(value) <= 4096 else { return nil } + return CGFloat(value) + } + private func refuse(_ parser: XMLParser) { failed = true; parser.abortParsing() } + func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) { refuse(parser) } + func parser(_ parser: XMLParser, resolveExternalEntityName name: String, systemID: String?) -> Data? { refuse(parser); return nil } + + private static func path(_ value: String) -> CGPath? { + guard value.utf8.count <= 500_000, let expression = try? NSRegularExpression(pattern: #"[MmLlHhVvZz]|[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.?)(?:[eE][-+]?[0-9]+)?"#) else { return nil } + let source = value as NSString + let matches = expression.matches(in: value, range: NSRange(location: 0, length: source.length)) + guard matches.count <= 100_000, !matches.isEmpty else { return nil } + let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",")) + var end = 0; var tokens: [String] = [] + for match in matches { + guard source.substring(with: NSRange(location: end, length: match.range.location - end)).trimmingCharacters(in: separators).isEmpty else { return nil } + tokens.append(source.substring(with: match.range)); end = NSMaxRange(match.range) + } + guard source.substring(from: end).trimmingCharacters(in: separators).isEmpty else { return nil } + let drawing = CGMutablePath() + let commands = Set("MmLlHhVvZz".map { String($0) }) + var index = 0; var command: String?; var point = CGPoint.zero; var start = CGPoint.zero; var hasMove = false + func read() -> CGFloat? { + guard index < tokens.count, let value = Double(tokens[index]), value.isFinite, abs(value) <= 4096 else { return nil } + index += 1; return CGFloat(value) + } + while index < tokens.count { + if commands.contains(tokens[index]) { command = tokens[index]; index += 1 } + guard let current = command else { return nil } + if current == "z" || current == "Z" { + guard hasMove else { return nil }; drawing.closeSubpath(); point = start; command = nil; continue + } + guard let first = read() else { return nil } + let relative = current == current.lowercased() + switch current.lowercased() { + case "m", "l": + guard let second = read() else { return nil } + point = CGPoint(x: first + (relative ? point.x : 0), y: second + (relative ? point.y : 0)) + if current.lowercased() == "m" { drawing.move(to: point); start = point; hasMove = true; command = relative ? "l" : "L" } + else { guard hasMove else { return nil }; drawing.addLine(to: point) } + case "h": guard hasMove else { return nil }; point.x = first + (relative ? point.x : 0); drawing.addLine(to: point) + case "v": guard hasMove else { return nil }; point.y = first + (relative ? point.y : 0); drawing.addLine(to: point) + default: return nil + } + guard abs(point.x) <= 8192, abs(point.y) <= 8192 else { return nil } + } + return hasMove ? drawing : nil + } +} diff --git a/SpaceWorks/Platform/NativeScanner.swift b/SpaceWorks/Platform/NativeScanner.swift new file mode 100644 index 0000000..4d8e512 --- /dev/null +++ b/SpaceWorks/Platform/NativeScanner.swift @@ -0,0 +1,149 @@ +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 useDataScanner = false + @State private var submitted = 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 { + 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") + } + if let message { + Label(message, systemImage: "camera") + .foregroundStyle(Color.swMuted) + } + Text("Enter code manually").brandFont(.headline) + TextField("Code", text: $manual) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .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) + } + .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 AVCaptureDevice.default(for: .video) != nil else { + message = String(localized: "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) + } + useDataScanner = DataScannerViewController.isSupported && DataScannerViewController.isAvailable + cameraReady = allowed + if !cameraReady { message = String(localized: "Camera access is unavailable. Enter the code below, or allow Camera in Settings.") } + } + + private func cameraUnavailable() { + cameraReady = false + message = String(localized: "Camera scanning is unavailable. Enter the code below.") + } + + 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 = String(localized: "Enter a valid code and try again.") + return + } + submitted = true + 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.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, value.utf8.count <= 2048, + value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) { + delivered = true + dataScanner.stopScanning() + onScan(value) + return + } + } + } + + func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { + dataScanner.stopScanning() + onUnavailable() + } + } +} diff --git a/SpaceWorks/ProviderButtons.swift b/SpaceWorks/ProviderButtons.swift new file mode 100644 index 0000000..c46b9e9 --- /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.buttonPressed), for: .touchUpInside) + return button + } + func updateUIView(_ view: GIDSignInButton, context: Context) { + context.coordinator.action = action + view.style = wide ? .wide : .standard + view.accessibilityLabel = String(localized: "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 buttonPressed() { 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.buttonPressed), 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 buttonPressed() { action() } + } +} diff --git a/SpaceWorks/PushRegistrationState.swift b/SpaceWorks/PushRegistrationState.swift new file mode 100644 index 0000000..784343c --- /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: 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) } + 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: String(localized: "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..9b21198 --- /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: 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/Fonts/Gilroy-Bold.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf new file mode 100644 index 0000000..55b0f7a Binary files /dev/null and b/SpaceWorks/Resources/Fonts/Gilroy-Bold.ttf differ diff --git a/SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf new file mode 100644 index 0000000..64e5e53 Binary files /dev/null and b/SpaceWorks/Resources/Fonts/Gilroy-Medium.ttf differ diff --git a/SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf b/SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf new file mode 100644 index 0000000..3ac2ab2 Binary files /dev/null and b/SpaceWorks/Resources/Fonts/Gilroy-Regular.ttf differ diff --git a/SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf b/SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf new file mode 100644 index 0000000..1ebab53 Binary files /dev/null and b/SpaceWorks/Resources/Fonts/Gilroy-SemiBold.ttf differ diff --git a/SpaceWorks/Resources/Fonts/Gilroy-provenance.json b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json new file mode 100644 index 0000000..efade2d --- /dev/null +++ b/SpaceWorks/Resources/Fonts/Gilroy-provenance.json @@ -0,0 +1,42 @@ +{ + "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", + "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/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..e084ebd --- /dev/null +++ b/SpaceWorks/Resources/Fonts/README.md @@ -0,0 +1,13 @@ +# Native Space Works fonts + +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 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. + +- 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 0000000..ea0ad43 Binary files /dev/null and b/SpaceWorks/Resources/Fonts/Unbounded-Variable.ttf differ diff --git a/SpaceWorks/Resources/Localizable.xcstrings b/SpaceWorks/Resources/Localizable.xcstrings new file mode 100644 index 0000000..75cab2f --- /dev/null +++ b/SpaceWorks/Resources/Localizable.xcstrings @@ -0,0 +1,9631 @@ +{ + "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 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": { + "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 app": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About this app" + } + } + } + }, + "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." + } + } + } + }, + "Account restored": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account restored" + } + } + } + }, + "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" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "App guide": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App guide" + } + } + } + }, + "App lock was not turned on.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "App lock was not turned on." + } + } + } + }, + "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 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": { + "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" + } + } + } + }, + "Awaiting issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Awaiting issue" + } + } + } + }, + "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 tools": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrow tools" + } + } + } + }, + "Borrow with confidence.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrow with confidence." + } + } + } + }, + "Borrowed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Borrowed" + } + } + } + }, + "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" + } + } + } + }, + "By month": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By month" + } + } + } + }, + "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 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": { + "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" + } + } + } + }, + "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": { + "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 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": { + "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" + } + } + } + }, + "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": { + "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 it's you": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm it's you" + } + } + } + }, + "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" + } + } + } + }, + "Continue with phone": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with phone" + } + } + } + }, + "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" + } + } + } + }, + "Dark mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark mode" + } + } + } + }, + "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" + } + } + } + }, + "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": { + "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 changes stay in this preview.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo changes stay in this preview." + } + } + } + }, + "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": { + "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 back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Due back" + } + } + } + }, + "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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the server address provided by your makerspace." + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the six-digit code sent to your email." + } + } + } + }, + "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": { + "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 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": { + "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" + } + } + } + }, + "Events attended": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Events attended" + } + } + } + }, + "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" + } + } + } + }, + "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": { + "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." + } + } + } + }, + "Find your way around": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find your way around" + } + } + } + }, + "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" + } + } + } + }, + "Handover queue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Handover queue" + } + } + } + }, + "Hardware requests": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hardware requests" + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hidden" + } + } + } + }, + "Hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hours" + } + } + } + }, + "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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you don't have a current password, sign out and use Forgot password to recover access." + } + } + } + }, + "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": { + "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" + } + } + } + }, + "Include your country code.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include your country code." + } + } + } + }, + "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." + } + } + } + }, + "Join events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join events" + } + } + } + }, + "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 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": { + "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": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave" + } + } + } + }, + "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." + } + } + } + }, + "Light mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light mode" + } + } + } + }, + "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 activity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading activity" + } + } + } + }, + "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โ€ฆ" + } + } + } + }, + "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": { + "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 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": { + "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" + } + } + } + }, + "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": { + "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." + } + } + } + }, + "Most used machines": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Most used machines" + } + } + } + }, + "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 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": { + "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." + } + } + } + }, + "Nothing needs your attention right now.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing needs your attention right now." + } + } + } + }, + "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 more step": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One more step" + } + } + } + }, + "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 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": { + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open PDF" + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open check-in" + } + } + } + }, + "Open in the app": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in the app" + } + } + } + }, + "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 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": { + "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" + } + } + } + }, + "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": { + "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โ€ฆ" + } + } + } + }, + "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": { + "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 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": { + "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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending payment" + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending review" + } + } + } + }, + "Phone code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone code" + } + } + } + }, + "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." + } + } + } + }, + "Pickups": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickups" + } + } + } + }, + "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" + } + } + } + }, + "Prints awaiting collection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prints awaiting collection" + } + } + } + }, + "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)" + } + } + } + }, + "Protect your account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Protect your account" + } + } + } + }, + "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" + } + } + } + }, + "Ready to issue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready to 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 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": { + "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." + } + } + } + }, + "Replay introduction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replay introduction" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset helpful tips" + } + } + } + }, + "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" + } + } + } + }, + "Resolve these first": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolve these first" + } + } + } + }, + "Restore a deleted account": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore a deleted account" + } + } + } + }, + "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 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": { + "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" + } + } + } + }, + "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": { + "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 another code later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send another code later" + } + } + } + }, + "Send verification code": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send verification code" + } + } + } + }, + "Server": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server" + } + } + } + }, + "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 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": { + "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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "Sign out device": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out device" + } + } + } + }, + "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": { + "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 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": { + "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" + } + } + } + }, + "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": { + "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 handovers and check-in": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Staff handovers and check-in" + } + } + } + }, + "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." + } + } + } + }, + "Stocktakes awaiting approval": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stocktakes awaiting approval" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "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": { + "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 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": { + "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 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": { + "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 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": { + "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 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": { + "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." + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ticket" + } + } + } + }, + "Ticket type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ticket type" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "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" + } + } + } + }, + "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": { + "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" + } + } + } + }, + "Upcoming events": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upcoming events" + } + } + } + }, + "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 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": { + "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 expired. Try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification expired. Try again." + } + } + } + }, + "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 again": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify again" + } + } + } + }, + "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." + } + } + } + }, + "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." + } + } + } + }, + "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": { + "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 profile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View profile" + } + } + } + }, + "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" + } + } + } + }, + "Warranty expiring": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warranty expiring" + } + } + } + }, + "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": { + "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)" + } + } + } + }, + "WhatsApp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WhatsApp" + } + } + } + }, + "Workingโ€ฆ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workingโ€ฆ" + } + } + } + }, + "Yes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + } + } + }, + "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": { + "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 is locked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account is locked" + } + } + } + }, + "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 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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your card QR code isn't available. Ask the front desk for help." + } + } + } + }, + "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": { + "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 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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in expired. Try again." + } + } + } + }, + "iOS": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS" + } + } + } + }, + "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": { + "en": { + "stringUnit": { + "state": "translated", + "value": "or with your email" + } + } + } + } + }, + "version": "1.0" +} diff --git a/SpaceWorks/Resources/PrivacyInfo.xcprivacy b/SpaceWorks/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..0ccaa27 --- /dev/null +++ b/SpaceWorks/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,108 @@ + + + + + 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 + + + + + diff --git a/SpaceWorks/Resources/en.lproj/InfoPlist.strings b/SpaceWorks/Resources/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..f93de8c --- /dev/null +++ b/SpaceWorks/Resources/en.lproj/InfoPlist.strings @@ -0,0 +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 3b028f0..bc333c9 100644 --- a/SpaceWorks/RootView.swift +++ b/SpaceWorks/RootView.swift @@ -1,27 +1,113 @@ import SwiftUI +import SpaceWorksAPI +#if DEBUG +import OSLog +#endif 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 + #endif var body: some View { Group { - switch state.screen { - case .connect: ConnectView(state: state) - case .shell: AppShell(state: state) + if !introComplete { + IntroductionView { introComplete = true } + } else { + #if DEBUG + if exploringDemo { + DemoRoot { + NativeDemoEntryDiagnostics.record(.rootExitInvoked, exploringDemo: exploringDemo) + exploringDemo = false + NativeDemoEntryDiagnostics.record(.rootExitCompleted, exploringDemo: exploringDemo) + } + } + else { liveContent } + #else + liveContent + #endif } } + .brandFont(.body) .tint(Color.swAccentInk) .foregroundStyle(Color.swInk) .preferredColorScheme(state.darkMode ? .dark : .light) .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: 44).padding(.horizontal, 24) + .padding(.horizontal, 24) .accessibilityIdentifier("appearance") }.background(Color.swSurface) } - .task { await state.restore() } + .nativePrivacyShield() + .nativeOrientationDiagnostics() + .safeAreaInset(edge: .bottom) { MobileLinkNotice(router: linkRouter) } + .task { + installTapHandler() + 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 } + #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, linkRouter: linkRouter) } + } + #if DEBUG + if state.screen == .connect { + 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 + } } } @@ -31,18 +117,19 @@ private struct ConnectView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { - Text(Brand.appName).font(.headline).foregroundStyle(Color.swMuted) - Text("Connect to your Space Works server").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader) - Text("Your makerspace will share its server URL with you.").foregroundStyle(Color.swMuted) + Text(Brand.appName).brandFont(.headline).foregroundStyle(Color.swMuted) + BrandWordmark() + 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 URL").font(.headline) - TextField("Server URL", text: $state.serverInput, + Text("Server address").brandFont(.headline) + TextField("Server address", text: $state.serverInput, prompt: Text(verbatim: "https://spaceworks.example").foregroundColor(Color.swMuted)) .foregroundStyle(Color.swInk) .textContentType(.URL).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() .padding(16).background(Color.swPanel, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.swOutline)) - .accessibilityLabel("Server URL").accessibilityIdentifier("server-url") + .accessibilityLabel("Server address").accessibilityIdentifier("server-url") .disabled(state.locked || state.connecting) .submitLabel(.go).onSubmit { Task { await state.connect() } } } @@ -82,33 +169,262 @@ enum AppTab: String, CaseIterable, Identifiable { } } -private struct AppShell: View { +struct AppShell: View { let state: AppState + let auth: AuthState + let linkRouter: MobileLinkRouter 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) + if let membership = auth.selectedMembership, let origin = state.connectedOrigin { + MemberWorkspace(state: state, auth: auth, origin: origin, id: membership.id, slug: membership.slug, linkRouter: linkRouter) + // 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: ","))") + } + } +} + +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() + @State private var cart: RequestCartState + @State private var drafts: MemberActionDrafts + @State private var capabilities: MemberCapabilities + @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 + 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() + let actions = LiveMemberActionsRepository(service: auth.service, expectedCredentials: auth.acceptedCredentials, id: id, slug: slug, + onUnauthorized: failureHandlers.unauthorized, onForbidden: failureHandlers.forbidden) + self.actions = actions + 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. + _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, + 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)) + _drafts = State(initialValue: MemberActionDrafts(repository: actions)) + _capabilities = State(initialValue: MemberCapabilities(repository: actions, makerspaceID: id)) + _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 { + GeometryReader { geometry in + VStack(spacing: 0) { + // 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) + }.buttonStyle(.plain).accessibilityIdentifier("nav-\(tab.rawValue)") + .foregroundStyle(navigation.selected == tab ? Color.swOnAccent : Color.swInk) + .listRowBackground(navigation.selected == tab ? Color.swSidebarActive : Color.swSidebar) + } + .scrollContentBackground(.hidden).background(Color.swSidebar) + .navigationTitle(Brand.appName) + } detail: { destination(navigation.selected) } + } else { + TabView(selection: $navigation.selected) { + ForEach(AppTab.allCases) { tab in + destination(tab) + .tabItem { Label(LocalizedStringKey(tab.rawValue), systemImage: tab.symbol) }.tag(tab) + .badge(tab == .inbox ? member.unreadCount ?? 0 : 0) + } + } + } + }.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 + } + }.task { + 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(); 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) } + .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() } } + } + .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, payment: { id in + try await PaymentsLinkDestination.open(id, state: payments, navigation: navigation) { linkRouter.isCurrent(approved) } + }) + } + } + .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, 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, insights: insights) { await auth.foreground() } + } + } + } + +} + +private struct MemberScreen: View { + let state: AppState + let auth: AuthState + let member: MemberState + let tab: AppTab + let actions: any MemberActionsRepository + let cart: RequestCartState + let drafts: MemberActionDrafts + let navigation: MemberNavigation + let capabilities: MemberCapabilities + 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 + @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 + 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() } + }, insights: insights) } + else { ScrollView { + VStack(alignment: .leading, spacing: 24) { + SWCard { + 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 { + Button("Confirm email") { accountForm = auth.accountForm(.emailVerification) }.frame(minHeight: 48) + } + 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) } + 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") } + 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) + }.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) + } } + }.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(); 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(); staff?.invalidate(); insights.invalidate() } + } + } + Button("Cancel", role: .cancel) {} + } message: { Text("This signs you out on every device and browser.") } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + 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(); payments.invalidate(); account?.invalidate(); staff?.invalidate() + Task { await auth.select(membership.id) } } - .background(Color.swBg) } - .navigationTitle(tab.rawValue) - }.tabItem { Label(tab.rawValue, systemImage: tab.symbol) } + } label: { Label(auth.selectedMembership?.slug ?? String(localized: "Choose a makerspace"), systemImage: "building.2") } + .accessibilityIdentifier("makerspace-switcher") } } - .tint(Color.swAccentInk) - // There is no Staff tab until A1 provides server-backed membership actions. } } + +#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/SpaceWorks/SpaceWorksApp.swift b/SpaceWorks/SpaceWorksApp.swift index 5c6ae9e..8cc3a2f 100644 --- a/SpaceWorks/SpaceWorksApp.swift +++ b/SpaceWorks/SpaceWorksApp.swift @@ -3,12 +3,31 @@ 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"), + let origin = try? ServerOrigin(saved), (try? TokenVault(origin: origin).load()) != nil { + // Upgrade an existing signed-in installation without inserting a tour into restoration. + UserDefaults.standard.set(true, forKey: "introduction-complete") + } #if DEBUG 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) + } + if ProcessInfo.processInfo.arguments.contains("--reset-introduction") { + UserDefaults.standard.set(false, forKey: "introduction-complete") + } else if !ProcessInfo.processInfo.arguments.contains("--introduction") { + UserDefaults.standard.set(true, forKey: "introduction-complete") + } + if let origin = try? ServerOrigin("https://ui-test.spaceworks.invalid") { try? TokenVault(origin: origin).store(nil) } } #endif } @@ -19,15 +38,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 +64,49 @@ 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 code = failure ? 503 : 200 - let body = failure ? #"{"detail":"Try again later.","code":"service_unavailable"}"# : #"{"status":"ok"}"# + 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 superadmin = arguments.contains("--superadmin") && path == "/api/v1/auth/device/login" + 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"}"# } + else if let member = PreviewMemberResponses.response(for: request) { body = member } + else { + switch path { + 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/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"]}}"# + 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 + 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."}"# + 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"}"# + } + } 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)) @@ -49,4 +114,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/SpaceWorks/StaffCheckInState.swift b/SpaceWorks/StaffCheckInState.swift new file mode 100644 index 0000000..1a4c76d --- /dev/null +++ b/SpaceWorks/StaffCheckInState.swift @@ -0,0 +1,53 @@ +import Foundation +import Observation +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 + 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() { 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 + let value = token.trimmingCharacters(in: .whitespacesAndNewlines) + 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; 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) + } + } + /// 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 = 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/StaffDemo.swift b/SpaceWorks/StaffDemo.swift new file mode 100644 index 0000000..ff30517 --- /dev/null +++ b/SpaceWorks/StaffDemo.swift @@ -0,0 +1,231 @@ +#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 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 + 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 changed() -> APIError { + 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() + 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 { + 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 { + 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 { + 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 { + var row = try request(id) + 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 } + 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 { + 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: 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" { + 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)) + } + 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 { + 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) + 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() + 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(); _ = 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() + try checked() + 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]) + } + 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: 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.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 .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) + 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: 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]) } +} +#endif diff --git a/SpaceWorks/StaffHandoverState.swift b/SpaceWorks/StaffHandoverState.swift new file mode 100644 index 0000000..edf8855 --- /dev/null +++ b/SpaceWorks/StaffHandoverState.swift @@ -0,0 +1,319 @@ +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 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. +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 + private(set) var feedback: NativeFeedbackEvent? + 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 ?? 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)) + 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() { 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 + 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 = 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 } + 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 ?? 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 { + 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 = 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 + 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 = String(localized: "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 = 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.") } + } + } + 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..dedd26a --- /dev/null +++ b/SpaceWorks/StaffHandoverView.swift @@ -0,0 +1,224 @@ +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)").brandFont(.subheadline).foregroundStyle(Color.swMuted) + ProgressView(value: Double(state.step + 1), total: Double(state.steps.count)).tint(Color.swAccentInk).accessibilityLabel("Handover progress") + 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) } + if state.busy { ProgressView("Workingโ€ฆ") } + if !state.parent.isActive { + 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 { + 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) + } + 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) + .nativeFeedback(state.feedback) + .interactiveDismissDisabled(state.busy) + .navigationBarBackButtonHidden(state.busy) + .task { + if directIssue { await state.loadBorrowers(); await state.loadProducts() } + } + .sheet(item: $scanner) { kind 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 = 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) { + 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").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") } + } + } + private var borrowerCheck: some View { + SWCard { + if let row = state.mode.request { + 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") + } + } + } + private var borrowers: some View { + SWCard { + 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) + 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).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).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)") + } + } + 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").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) { + 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")") + } + } + } + } + private var returnedItems: some View { + ForEach(state.returns.indices, id: \.self) { index in + SWCard { + 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 { + 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").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) + #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").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 + 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)").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) + } + } + private func prepareCamera() async { + do { + switch try await NativeCameraAccess.request() { + case .ready: cameraMessage = nil; camera = true + 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 = String(localized: "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..e938428 --- /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.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 + 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: String(localized: "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: 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: 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) + } + 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.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 + } + 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..208aaaa --- /dev/null +++ b/SpaceWorks/StaffState.swift @@ -0,0 +1,184 @@ +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 + 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] = [] + 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? + // 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 + } + 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) } + 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 = 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 = 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 { + 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 ? 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) } + } + } + func refuse(_ error: Error) { + if let deadline = APIError.retryDeadline(error), deadline > retryAfter { + retryAfter = deadline; cooldownTask?.cancel() + cooldownTask = Task { [weak self] in + 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)) { + invalidate(); self.error = APIError.display(error) + notice = String(localized: "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: String(localized: "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..cc342cb --- /dev/null +++ b/SpaceWorks/StaffViews.swift @@ -0,0 +1,262 @@ +import SwiftUI +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") + } + if state.can("issue_direct_loan") || state.canReturn { + 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: Text("Booking approvals"), detail: "Review space reservations", symbol: "calendar") }.accessibilityIdentifier("staff-bookings") + } + if state.can("manage_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.").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") + } +} + +private struct StaffMenuRow: View { + let title: Text; let detail: LocalizedStringKey; let symbol: String + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var body: some View { + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 8) { + 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.brandFont(.headline); Text(detail).brandFont(.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).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").brandFont(.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).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).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") { + 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).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) + } + } + } + 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).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) } + 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 ?? String(localized: "Your access changed. Return to your makerspace before continuing.")) + Button("Close") { dismiss() }.buttonStyle(SWPrimary()) + } else { + 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").brandFont(.headline).accessibilityIdentifier("staff-attendance-recorded") } + if let person = state.person { + SWCard { + 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) } + } + } + } + }.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) } } } + } +} + +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: 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) } + }.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).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" { + 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/AccountAccessTests.swift b/SpaceWorksTests/AccountAccessTests.swift new file mode 100644 index 0000000..ecd267a --- /dev/null +++ b/SpaceWorksTests/AccountAccessTests.swift @@ -0,0 +1,96 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class AccountAccessTests: XCTestCase { + private func form(step: AccountAccessState.Step = .requestReset, now: @escaping () -> 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/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/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"]) + } +} 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/AccountOperationTests.swift b/SpaceWorksTests/AccountOperationTests.swift new file mode 100644 index 0000000..7a35e4b --- /dev/null +++ b/SpaceWorksTests/AccountOperationTests.swift @@ -0,0 +1,368 @@ +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 { #"{"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"]!)) + } + 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(DeviceRefreshFixture.rotated) } + 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 { + 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 + 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 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(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() + 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 { + 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") } + 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(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") + 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(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) + } + 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 let entryOrCompletion: XCTestExpectation + private var started = false + 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; lock.unlock() + signal() + XCTAssertEqual(response.wait(timeout: .now() + 10), .success, "Held management response was not released") + } + 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/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/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/AuthTests.swift b/SpaceWorksTests/AuthTests.swift new file mode 100644 index 0000000..68d83da --- /dev/null +++ b/SpaceWorksTests/AuthTests.swift @@ -0,0 +1,250 @@ +import XCTest +import Foundation +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); 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); XCTAssertNil(auth.feedback) + } + 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) } + 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") }) + let credentials = await coordinator.snapshot() + do { _ = try await coordinator.recover(rejected: credentials); 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) + } + 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 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") { 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"]) + changed = true + return (200, #"{"detail":"Updated."}"#) + } + 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") + 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, DeviceRefreshFixture.rotated) } + 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() + 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/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/SpaceWorksTests/CooldownCopyTests.swift b/SpaceWorksTests/CooldownCopyTests.swift new file mode 100644 index 0000000..133da76 --- /dev/null +++ b/SpaceWorksTests/CooldownCopyTests.swift @@ -0,0 +1,31 @@ +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) + 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/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/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/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 new file mode 100644 index 0000000..509bb43 --- /dev/null +++ b/SpaceWorksTests/EmailOwnerTests.swift @@ -0,0 +1,193 @@ +#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); XCTAssertNil(old.feedback) + 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); 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.") + } + 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) + 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) + 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 = DeviceRefreshFixture.rotated + 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/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/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/SpaceWorksTests/MemberActionsNetworkingTests.swift b/SpaceWorksTests/MemberActionsNetworkingTests.swift new file mode 100644 index 0000000..0ffed90 --- /dev/null +++ b/SpaceWorksTests/MemberActionsNetworkingTests.swift @@ -0,0 +1,94 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +final class MemberActionsNetworkingTests: XCTestCase { + func testGeneratedMemberWritesUseOnlyContractFieldsAndAuthoritativeStatusResults() async throws { + let token = "a8a33ab0-c138-4c0a-85eb-fdcb6b75318f" + let now = Date(timeIntervalSince1970: 1_800_000_000) + var calls = 0 + StubProtocol.lock.lock() + StubProtocol.handler = { request in + calls += 1 + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer native-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: "/")) + var status = 200; var type = "application/json"; let json: String + if path == "api/v1/bootstrap" { + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "slug" })?.value, "fab-lab-kochi") + json = DebugMemberFixtures.bootstrap + } else if path.hasSuffix("/requests") { + XCTAssertEqual(request.httpMethod, "POST"); XCTAssertEqual(request.value(forHTTPHeaderField: "Idempotency-Key"), "reviewed-attempt") + let body = try Self.body(request) + XCTAssertEqual(Set(body.keys), Set(["requested_for", "items"])) + let item = (body["items"] as! [[String: Any]])[0] + XCTAssertEqual(Set(item.keys), Set(["product_id", "quantity"])); XCTAssertEqual(item["quantity"] as? Int, 2) + status = 201; json = "{\"public_token\":\"\(token)\",\"status\":\"pending\"}" + } else if path.hasSuffix("/renew") { + XCTAssertEqual(request.httpMethod, "POST"); XCTAssertNil(request.httpBody); XCTAssertNil(request.httpBodyStream) + json = #"{"id":4,"return_due_at":"2027-01-25T08:00:00Z","renewal_count":2}"# + } else if path.hasSuffix("/availability") { + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!.queryItems! + XCTAssertNotNil(query.first { $0.name == "starts_at" }); XCTAssertNotNil(query.first { $0.name == "ends_at" }) + json = "{\"public_token\":\"\(token)\",\"starts_at\":\"2027-01-15T08:00:00Z\",\"ends_at\":\"2027-01-15T09:00:00Z\",\"timezone\":\"Asia/Kolkata\",\"opening_intervals\":[],\"availability\":null}" + } else if path.hasSuffix("/book") { + XCTAssertEqual(request.httpMethod, "POST") + let body = try Self.body(request); XCTAssertNotNil(body["starts_at"]); XCTAssertNotNil(body["ends_at"]); XCTAssertNil(body["status"]) + status = 201; json = #"{"status":"pending"}"# + } else if path.hasSuffix("/register") { + XCTAssertEqual(request.httpMethod, "POST"); let body = try Self.body(request) + XCTAssertEqual(body["ticket_type_id"] as? Int, 1); XCTAssertNil(body["registration_id"]) + status = 201; json = #"{"status":"waitlisted"}"# + } else if path.contains("/my-bookings/") && path.hasSuffix("/cancel") { + XCTAssertEqual(request.httpMethod, "POST") + let fixture = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.bookings.utf8)) as! [String: Any] + var row = (fixture["results"] as! [[String: Any]])[0]; row["status"] = "cancelled"; row["can_cancel"] = false; row["public_token"] = token + json = String(decoding: try JSONSerialization.data(withJSONObject: row), as: UTF8.self) + } else if path.hasSuffix("/cancel") { + XCTAssertEqual(request.httpMethod, "POST"); json = #"{"status":"cancelled"}"# + } else if path.hasSuffix("/qr") { + type = "image/svg+xml"; json = #""# + } else if path.hasSuffix("/profile") { + if request.httpMethod == "PATCH" { let body = try Self.body(request); XCTAssertEqual(Set(body.keys), Set(["display_name"])) } + json = #"{"display_name":"Meera Maker","first_name":"Meera","last_name":"Nair","phone":""}"# + } else if path.contains("/preferences/") { + if request.httpMethod == "PATCH" { let body = try Self.body(request); XCTAssertEqual(body["enabled"] as? Bool, false); XCTAssertNil(body["mandatory_events"]) } + json = #"{"cells":[]}"# + } else { XCTFail("Unexpected generated action route: \(path)"); json = "{}" } + return (status, ["Content-Type": type], Data(json.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.makerspace.select(7, activeMemberships: [7]) + try await service.withMakerspace(7) { + let bootstrap = try await service.memberBootstrap(slug: "fab-lab-kochi"); XCTAssertEqual(bootstrap.makerspace.id, 7) + let receipt = try await service.submitMemberRequest(slug: "fab-lab-kochi", items: [.init(product_id: 1, quantity: 2)], purpose: "A project", idempotencyKey: "reviewed-attempt") + XCTAssertEqual(receipt.public_token, token); XCTAssertEqual(receipt.status, "pending") + 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))) + 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) + let cancelled = try await service.cancelMemberEvent(id: 7, registration: 77); XCTAssertEqual(cancelled.status.value1, .cancelled) + let qr = try await service.memberEventQR(id: 7, registration: 77); XCTAssertTrue(String(decoding: qr, as: UTF8.self).contains(" [String: Any] { + if let data = request.httpBody { return try JSONSerialization.jsonObject(with: data) as! [String: Any] } + if let stream = request.httpBodyStream { + stream.open(); defer { stream.close() }; var data = Data(); var bytes = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { let count = stream.read(&bytes, maxLength: bytes.count); if count <= 0 { break }; data.append(contentsOf: bytes.prefix(count)) } + return try JSONSerialization.jsonObject(with: data) as! [String: Any] + } + XCTFail("The generated JSON request body is missing"); return [:] + } +} diff --git a/SpaceWorksTests/MemberActionsTests.swift b/SpaceWorksTests/MemberActionsTests.swift new file mode 100644 index 0000000..975d23f --- /dev/null +++ b/SpaceWorksTests/MemberActionsTests.swift @@ -0,0 +1,153 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class MemberActionsTests: XCTestCase { + func testCartRetryRetainsIdempotencyWhileEditingCreatesNewAttempt() async throws { + 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) + let input = [Components.Schemas.RequestItemInput(product_id: 1, quantity: 2)] + let initial = try JSONSerialization.jsonObject(with: Data(await store.activity().utf8)) as! [String: Any] + let before = (initial["request_history"] as! [[String: Any]]).count + let first = try await repository.submit(items: input, purpose: "A project", key: "stable-attempt") + let second = try await repository.submit(items: input, purpose: "A project", key: "stable-attempt") + XCTAssertEqual(first.public_token, second.public_token) + let final = try JSONSerialization.jsonObject(with: Data(await store.activity().utf8)) as! [String: Any] + XCTAssertEqual((final["request_history"] as! [[String: Any]]).count, before + 1) + do { _ = try await repository.submit(items: input, purpose: "Changed project", key: "stable-attempt"); XCTFail("Reused key accepted different content") } + catch { XCTAssertEqual(APIError.status(error), 409) } + } + func testProfileFailurePreservesUnsavedEditsAndExplicitDiscardRestoresConfirmedData() async { + let repository = DemoMemberActionsRepository(); let state = ProfileEditorState(repository: repository) + await state.load(); XCTAssertTrue(state.loaded) + state.displayName = "Meera Maker"; XCTAssertTrue(state.dirty) + await repository.preview(.refused); await state.save() + XCTAssertEqual(state.displayName, "Meera Maker"); XCTAssertTrue(state.dirty); XCTAssertNotNil(state.error) + state.discard(); XCTAssertEqual(state.displayName, "Meera Nair"); XCTAssertFalse(state.dirty) + await repository.preview(.success); state.displayName = "Meera Maker"; await state.save() + XCTAssertEqual(state.notice, "Profile saved."); XCTAssertFalse(state.dirty) + } + func testMandatoryMessagesDoNotLockOptionalPreferencesAndUnavailableCannotEnable() async throws { + let repository = DemoMemberActionsRepository(); let state = CommunicationPreferencesState(repository: repository) + await state.load() + let safety = try XCTUnwrap(state.cells.first { !$0.mandatory_events.isEmpty }) + await state.set(safety, enabled: false) + XCTAssertEqual(state.cells.first { $0.feature == safety.feature && $0.channel == safety.channel }?.enabled, false) + XCTAssertEqual(state.cells.first { $0.feature == safety.feature && $0.channel == safety.channel }?.mandatory_events, safety.mandatory_events) + let unavailable = try XCTUnwrap(state.cells.first { !$0.available }) + await state.set(unavailable, enabled: true) + XCTAssertNotNil(state.error); XCTAssertEqual(state.cells.first { !$0.available }?.enabled, false) + } + func testPreferenceWriteRefusalKeepsLastConfirmedValue() async throws { + let repository = DemoMemberActionsRepository(); let state = CommunicationPreferencesState(repository: repository) + await state.load(); let cell = try XCTUnwrap(state.cells.first) + await repository.preview(.refused); await state.set(cell, enabled: !cell.enabled) + XCTAssertEqual(state.cells.first?.enabled, cell.enabled); XCTAssertNotNil(state.error) + } + func testBookingEditsInvalidateAvailabilityAndConflictNeverBecomesConfirmed() async throws { + let repository = DemoMemberActionsRepository() + let spaces = try await DemoMemberRepository().spaces() + let space = try XCTUnwrap(spaces.first) + let state = BookingState(repository: repository, space: space) + await state.check(); XCTAssertNotNil(state.availability) + state.end = state.end.addingTimeInterval(60); XCTAssertNil(state.availability) + 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?.pending, false) + } + func testUnknownCustomQuestionAndMalformedNumbersFailClosed() throws { + let form = MemberCustomForm(data: Data(#"[{"id":"q1","label":"How many?","type":"number","required":true,"options":[]}]"#.utf8)) + form.text["q1"] = "12oops"; XCTAssertNil(form.validatedAnswers()); XCTAssertNotNil(form.errors["q1"]) + form.text["q1"] = "12"; XCTAssertNotNil(form.validatedAnswers()) + let unknown = MemberCustomForm(data: Data(#"[{"id":"q1","label":"Unknown field","type":"future_secret","required":true,"options":[]}]"#.utf8)) + XCTAssertTrue(unknown.unavailable); XCTAssertNil(unknown.validatedAnswers()) + XCTAssertTrue(MemberCustomForm(data: Data("not-json".utf8)).unavailable) + } + func testEventResultsRemainServerSelectedAndTicketUsesActualActivityRegistrationID() async throws { + let store = DemoMemberStore(); let repository = DemoMemberActionsRepository(store: store) + let rows = try await DemoMemberRepository().events(); let event = try XCTUnwrap(rows.first) + for scenario in [DemoMemberActionsRepository.Scenario.pending, .waitlisted, .success] { + await repository.preview(scenario) + let state = EventRegistrationState(repository: repository, event: event) + await state.submit() + XCTAssertEqual(state.result?.status.value1.rawValue, scenario == .pending ? "pending_approval" : scenario == .waitlisted ? "waitlisted" : "registered") + let reader = DemoMemberRepository(store: store); let activity = try await reader.activity() + let registration = try XCTUnwrap(activity.event_registrations?.first { $0.registration_id == 777 }) + XCTAssertEqual(registration.status, state.result?.status.value1.rawValue) + if scenario == .success { + let qr = try await repository.eventQR(registration: registration.registration_id); XCTAssertNotNil(NativeQRSVG.image(qr)) + } else { + do { _ = try await repository.eventQR(registration: registration.registration_id); XCTFail("Unconfirmed ticket returned a QR") } + catch { XCTAssertEqual(APIError.status(error), 403) } + } + } + _ = try await repository.cancelEvent(registration: 777) + do { _ = try await repository.eventQR(registration: 777); XCTFail("Cancelled ticket returned a QR") } + catch { XCTAssertEqual(APIError.status(error), 403) } + } + func testDemoMutationRefetchUpdatesActivityAndNativeDraftInvalidationPreventsNewWrites() async throws { + let store = DemoMemberStore(); let repository = DemoMemberActionsRepository(store: store) + let reader = DemoMemberRepository(store: store) + let before = try await reader.activity().request_history?.count ?? 0 + let cart = RequestCartState(repository: repository); cart.add(try await reader.product(1)); await cart.submit() + let after = try await reader.activity().request_history?.count ?? 0 + XCTAssertEqual(after, before + 1) + cart.invalidate(); cart.add(try await reader.product(1)); await cart.submit() + XCTAssertNil(cart.receipt); XCTAssertTrue(cart.lines.isEmpty) + } + + func testBootstrapGatesProfileAndPreferencesAndWrongMakerspaceFailsClosed() async throws { + let valid = MemberCapabilities(repository: DemoMemberActionsRepository(), makerspaceID: 7) + XCTAssertFalse(valid.selfService); XCTAssertFalse(valid.preferences) + await valid.load(); XCTAssertTrue(valid.selfService); XCTAssertTrue(valid.preferences); XCTAssertTrue(valid.digests) + let wrong = MemberCapabilities(repository: DemoMemberActionsRepository(), makerspaceID: 99) + await wrong.load(); XCTAssertNil(wrong.bootstrap); XCTAssertFalse(wrong.selfService); XCTAssertNotNil(wrong.error) + var object = try JSONSerialization.jsonObject(with: Data(DebugMemberFixtures.bootstrap.utf8)) as! [String: Any] + object["modules"] = []; object["features"] = [] + let json = String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self) + let disabled = MemberCapabilities(repository: DemoMemberActionsRepository(bootstrapJSON: json), makerspaceID: 7) + await disabled.load(); XCTAssertFalse(disabled.selfService); XCTAssertFalse(disabled.preferences) + valid.invalidate(); XCTAssertNil(valid.bootstrap) + } + func testDisabledDigestFeatureDoesNotPreventOptionalChannelUpdates() async throws { + let state = CommunicationPreferencesState(repository: DemoMemberActionsRepository()) + await state.load(); let email = try XCTUnwrap(state.cells.first { $0.channel.rawValue == "email" }) + state.digestsAllowed = false + await state.digest(email, enabled: true) + XCTAssertEqual(state.cells.first?.daily_digest, false) + await state.set(email, enabled: false) + XCTAssertEqual(state.cells.first?.enabled, false); XCTAssertEqual(state.cells.first?.daily_digest, false) + } + + func testTicketThrottleClearsPreviousQRCodeAndCancellationNeverLeavesItVisible() async throws { + let store = DemoMemberStore(); let repository = DemoMemberActionsRepository(store: store) + _ = try await repository.register(event: "sample", input: .init()) + let activity = try await DemoMemberRepository(store: store).activity() + let registration = try XCTUnwrap(activity.event_registrations?.first) + let state = MemberTicketState(registration: registration, repository: repository) + await state.load(); XCTAssertNotNil(state.image) + await repository.preview(.throttled); await state.load(); XCTAssertNil(state.image) + let deadline = try XCTUnwrap(state.retryAfter) + await repository.preview(.success); await state.load(); XCTAssertNil(state.image) + await state.load(now: deadline.addingTimeInterval(1)); XCTAssertNotNil(state.image) + await state.cancel(now: deadline.addingTimeInterval(1)); XCTAssertTrue(state.cancelled); XCTAssertNil(state.image) + await state.load(now: deadline.addingTimeInterval(1)); XCTAssertNil(state.image) + state.invalidate(); XCTAssertNil(state.image) + } + +} diff --git a/SpaceWorksTests/MemberNavigationTests.swift b/SpaceWorksTests/MemberNavigationTests.swift new file mode 100644 index 0000000..66bfa55 --- /dev/null +++ b/SpaceWorksTests/MemberNavigationTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import SpaceWorks + +@MainActor final class MemberNavigationTests: XCTestCase { + func testAdaptiveBranchesUseSamePerTabHistoryAndPreserveOtherDestinations() { + let state = MemberNavigation() + state.path(.inventory).wrappedValue = [.product(3)] + state.selected = .profile; state.path(.profile).wrappedValue = [.contact] + state.selected = .inventory + XCTAssertEqual(state.path(.inventory).wrappedValue, [.product(3)]) + XCTAssertEqual(state.path(.profile).wrappedValue, [.contact]) + } + func testCompactInventoryKeepsTabletSelectionAndDoesNotDuplicateExistingHistory() { + let state = MemberNavigation(); state.selected = .inventory; state.inventorySelection = 4 + state.compactInventory(); XCTAssertEqual(state.path(.inventory).wrappedValue, [.product(4)]) + state.compactInventory(); XCTAssertEqual(state.path(.inventory).wrappedValue.count, 1) + } + func testContextInvalidationRemovesProtectedNavigationAndSelection() { + let state = MemberNavigation(); state.open(.registration(11), tab: .activity); state.inventorySelection = 4 + state.invalidate() + XCTAssertTrue(state.paths.isEmpty); XCTAssertNil(state.inventorySelection); XCTAssertEqual(state.selected, .home) + } + func testProfileAndBookingDraftsSurviveLayoutReconstructionUntilContextInvalidation() async throws { + let repository = DemoMemberActionsRepository() + let drafts = MemberActionDrafts(repository: repository) + await drafts.profile.load(); drafts.profile.displayName = "Unsaved maker" + let spaces = try await DemoMemberRepository().spaces(); let space = try XCTUnwrap(spaces.first) + let first = drafts.booking(space); first.start = first.start.addingTimeInterval(600) + let reconstructed = drafts.booking(space) + XCTAssertTrue(first === reconstructed); XCTAssertEqual(reconstructed.start, first.start) + XCTAssertEqual(drafts.profile.displayName, "Unsaved maker") + drafts.invalidate(); XCTAssertEqual(drafts.profile.displayName, "") + await first.check(); XCTAssertNil(first.availability) + } + func testPoppingCompactInventoryDoesNotReopenThePreviousTabletSelection() { + let state = MemberNavigation(); state.selected = .inventory; state.inventorySelection = 4 + state.compactInventory(); state.path(.inventory).wrappedValue = [] + state.compactInventory(); XCTAssertTrue(state.path(.inventory).wrappedValue.isEmpty); XCTAssertNil(state.inventorySelection) + } + +} diff --git a/SpaceWorksTests/MemberNetworkingTests.swift b/SpaceWorksTests/MemberNetworkingTests.swift new file mode 100644 index 0000000..d0e1dec --- /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, 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/MemberProductTests.swift b/SpaceWorksTests/MemberProductTests.swift new file mode 100644 index 0000000..c9e2fcc --- /dev/null +++ b/SpaceWorksTests/MemberProductTests.swift @@ -0,0 +1,40 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +@MainActor final class MemberProductTests: XCTestCase { + func testProductRetryDeadlineSurvivesReusingTheDetailDraftAndExpiryAllowsRetry() async throws { + let counter = ProductCounter(); let now = Date(); let deadline = now.addingTimeInterval(60) + let product = try await DemoMemberRepository().product(1) + let state = MemberProductState(id: 1) { + let call = await counter.increment() + if call == 1 { throw APIError(status: 429, code: "throttled", detail: "Try later.", retryAfter: deadline) } + return product + } + await state.load(now: now); await state.load(now: now) + let before = await counter.calls; XCTAssertEqual(before, 1); XCTAssertNil(state.product) + XCTAssertEqual(state.retryAfter, deadline) + await state.load(now: deadline.addingTimeInterval(1)) + let after = await counter.calls; XCTAssertEqual(after, 2); XCTAssertEqual(state.product?.id, 1) + } + func testLateProductCannotReturnAfterAccountInvalidationOrStartNewRequest() async throws { + let gate = ProductGate(); let product = try await DemoMemberRepository().product(1) + let state = MemberProductState(id: 1) { await gate.wait() } + let pending = Task { await state.load() }; await gate.requested() + state.invalidate(); await gate.resolve(product); await pending.value + await state.load(); XCTAssertNil(state.product) + let calls = await gate.calls; XCTAssertEqual(calls, 1) + } +} +private actor ProductCounter { var calls = 0; func increment() -> Int { calls += 1; return calls } } +private actor ProductGate { + var calls = 0 + private var continuation: CheckedContinuation? + private var observer: CheckedContinuation? + func wait() async -> Components.Schemas.PublicProduct { + calls += 1 + return await withCheckedContinuation { continuation = $0; observer?.resume(); observer = nil } + } + func requested() async { if continuation != nil { return }; await withCheckedContinuation { observer = $0 } } + func resolve(_ product: Components.Schemas.PublicProduct) { continuation?.resume(returning: product); continuation = nil } +} diff --git a/SpaceWorksTests/MemberReadCooldownTests.swift b/SpaceWorksTests/MemberReadCooldownTests.swift new file mode 100644 index 0000000..ccc7507 --- /dev/null +++ b/SpaceWorksTests/MemberReadCooldownTests.swift @@ -0,0 +1,153 @@ +import XCTest +import SpaceWorksAPI +@testable import SpaceWorks + +#if DEBUG +@MainActor final class MemberReadCooldownTests: XCTestCase { + private let start = Date(timeIntervalSince1970: 1_000) + func testReadTasksKeepIndependentRetryDeadlinesAndIssueNoRequestBeforeTheirDeadline() async { + var now = start + let end = start.addingTimeInterval(60) + let repository = CooldownMemberRepository(deadline: end) + await repository.fail(Set(["inventory", "categories", "activity", "bookings", "events", "spaces", "card", "inbox", "unread"])) + let state = MemberState(repository: repository, now: { now }) + await allReads(state) + await allReads(state) + let blocked = await repository.counts + for role in ["inventory", "categories", "activity", "bookings", "events", "spaces", "card", "inbox", "unread"] { + XCTAssertEqual(blocked[role], 1, role) + XCTAssertEqual(state.deadline(role), end, role) + XCTAssertFalse(state.canLoad(role), role) + } + now = end.addingTimeInterval(1) + await repository.fail([]) + await state.loadInventory(); await state.loadActivity(); await state.loadBookings(); await state.loadEvents() + await state.loadSpaces(); await state.loadCard(); await state.loadInbox() + let resumed = await repository.counts + for role in ["inventory", "categories", "activity", "bookings", "events", "spaces", "card", "inbox", "unread"] { + XCTAssertEqual(resumed[role], 2, role) + XCTAssertTrue(state.canLoad(role), role) + } + } + func testInboxReadAndMutationsShareCooldownAndCannotReplayThroughAnotherButton() async { + var now = start + let end = start.addingTimeInterval(60) + let repository = CooldownMemberRepository(deadline: end) + await repository.fail(["inbox"]) + let state = MemberState(repository: repository, now: { now }) + await state.loadInbox(); await state.markRead(1); await state.markAllRead() + let first = await repository.counts + XCTAssertEqual(first["inbox"], 1); XCTAssertNil(first["mark-read"]); XCTAssertNil(first["mark-all"]) + now = end.addingTimeInterval(1) + await repository.fail(["mark-read"]) + await repository.setDeadline(now.addingTimeInterval(60)) + await state.markRead(1); await state.markAllRead(); await state.loadInbox() + let second = await repository.counts + XCTAssertEqual(second["mark-read"], 1); XCTAssertNil(second["mark-all"]); XCTAssertEqual(second["inbox"], 1) + } + func testInvalidationBlocksEveryReadAndMutationBeforeCallingTheRepository() async { + let repository = CooldownMemberRepository(deadline: start.addingTimeInterval(60)) + let state = MemberState(repository: repository) + state.invalidate() + await allReads(state); await state.loadHome(); await state.markRead(1); await state.markAllRead() + let calls = await repository.counts + XCTAssertTrue(calls.isEmpty) + XCTAssertTrue(state.retryDeadlines.isEmpty) + } + func testLateThrottledResponseAfterLogoutCannotPublishPrivateDataOrCooldown() async { + let repository = CooldownMemberRepository(deadline: start.addingTimeInterval(60)) + await repository.deferActivity() + await repository.fail(["activity"]) + let state = MemberState(repository: repository, now: { self.start }) + let pending = Task { await state.loadActivity() } + await repository.waitForActivity() + state.invalidate() + await repository.releaseActivity() + await pending.value + XCTAssertNil(state.activity) + XCTAssertNil(state.deadline("activity")) + await state.loadActivity() + let count = await repository.counts["activity"] + XCTAssertEqual(count, 1) + } + func testThrottledRefreshRetainsPreviouslyLoadedActivityAndItsTimestamp() async { + let repository = CooldownMemberRepository(deadline: start.addingTimeInterval(60)) + let state = MemberState(repository: repository, now: { self.start }) + await state.loadActivity() + XCTAssertNotNil(state.activity) + let stamp = state.updated["activity"] + await repository.fail(["activity"]) + await state.loadActivity() + XCTAssertNotNil(state.activity) + XCTAssertEqual(state.updated["activity"], stamp) + XCTAssertEqual(state.activityLoad, .failed("Please wait.")) + await state.loadActivity() + let count = await repository.counts["activity"] + XCTAssertEqual(count, 2) + } + func testCategoryThrottleDoesNotFailInventoryOrRetryHiddenFilterFetchOnEverySearch() async { + let repository = CooldownMemberRepository(deadline: start.addingTimeInterval(60)) + await repository.fail(["categories"]) + let state = MemberState(repository: repository, now: { self.start }) + await state.loadInventory() + XCTAssertEqual(state.inventoryLoad, .ready) + XCTAssertEqual(state.categoriesLoad, .failed("Please wait.")) + state.search = "drill" + await state.loadInventory() + let calls = await repository.counts + XCTAssertEqual(calls["inventory"], 2); XCTAssertEqual(calls["categories"], 1) + XCTAssertNotNil(state.deadline("categories")) + } + func testUnreadCounterThrottleIsNotSilentlyRetriedBySuccessfulInboxRefresh() async { + let repository = CooldownMemberRepository(deadline: start.addingTimeInterval(60)) + await repository.fail(["unread"]) + let state = MemberState(repository: repository, now: { self.start }) + await state.loadUnreadCount(); await state.loadInbox(); await state.loadInbox() + let calls = await repository.counts + XCTAssertEqual(calls["unread"], 1); XCTAssertEqual(calls["inbox"], 2) + XCTAssertEqual(state.inboxLoad, .ready) + } + private func allReads(_ state: MemberState) async { + await state.loadInventory(); await state.loadCategories(); await state.loadActivity(); await state.loadBookings() + await state.loadEvents(); await state.loadSpaces(); await state.loadCard(); await state.loadInbox(); await state.loadUnreadCount() + } +} + +private actor CooldownMemberRepository: MemberRepository { + private let demo = DemoMemberRepository() + private var failures = Set() + private var deadline: Date + private(set) var counts: [String: Int] = [:] + private var delayedActivity = false + private var activityStarted = false + private var activityWait: CheckedContinuation? + private var startWait: CheckedContinuation? + init(deadline: Date) { self.deadline = deadline } + func fail(_ roles: Set) { failures = roles } + func setDeadline(_ value: Date) { deadline = value } + func deferActivity() { delayedActivity = true } + func waitForActivity() async { if !activityStarted { await withCheckedContinuation { startWait = $0 } } } + func releaseActivity() { activityWait?.resume(); activityWait = nil } + private func called(_ role: String) throws { + counts[role, default: 0] += 1 + if failures.contains(role) { throw APIError(status: 429, code: "throttled", detail: "Please wait.", retryAfter: deadline) } + } + func inventory(page: Int, search: String, category: String?) async throws -> Components.Schemas.PaginatedPublicProductList { + try called("inventory"); return try await demo.inventory(page: page, search: search, category: category) + } + func product(_ id: Int) async throws -> Components.Schemas.PublicProduct { try called("product"); return try await demo.product(id) } + func categories() async throws -> [Components.Schemas.PublicCategory] { try called("categories"); return try await demo.categories() } + func activity() async throws -> Components.Schemas.MemberActivity { + if delayedActivity { activityStarted = true; startWait?.resume(); startWait = nil; await withCheckedContinuation { activityWait = $0 } } + try called("activity"); return try await demo.activity() + } + func bookings(page: Int) async throws -> Components.Schemas.MemberBookingList { try called("bookings"); return try await demo.bookings(page: page) } + func events() async throws -> [Components.Schemas.PublicEvent] { try called("events"); return try await demo.events() } + func spaces() async throws -> [Components.Schemas.PublicBookableSpace] { try called("spaces"); return try await demo.spaces() } + func card() async throws -> Components.Schemas.OwnMemberCard { try called("card"); return try await demo.card() } + func inbox(page: Int, unread: Bool) async throws -> Components.Schemas.InboxPage { try called("inbox"); return try await demo.inbox(page: page, unread: unread) } + func markRead(_ id: Int) async throws { try called("mark-read"); try await demo.markRead(id) } + func markAllRead() async throws { try called("mark-all"); try await demo.markAllRead() } + func unreadCount() async throws -> Int { try called("unread"); return try await demo.unreadCount() } +} +#endif 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/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))) + } +} 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..3f7ef32 --- /dev/null +++ b/SpaceWorksTests/MobileLinkTests.swift @@ -0,0 +1,47 @@ +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)) + // 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)) } + } +} 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/SpaceWorksTests/NativeAuthTests.swift b/SpaceWorksTests/NativeAuthTests.swift new file mode 100644 index 0000000..84ad5de --- /dev/null +++ b/SpaceWorksTests/NativeAuthTests.swift @@ -0,0 +1,254 @@ +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"] + 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" + 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 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) + 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/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/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/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 b172222..d8114f5 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 @@ -119,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)) } @@ -134,6 +195,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)) } @@ -181,7 +243,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() } @@ -194,3 +256,12 @@ private 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/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/PaymentsTests.swift b/SpaceWorksTests/PaymentsTests.swift new file mode 100644 index 0000000..aeb372a --- /dev/null +++ b/SpaceWorksTests/PaymentsTests.swift @@ -0,0 +1,431 @@ +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 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("{\"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 + 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 configureRazorpay() { razorpay = true } + 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 razorpay ? PaymentFixture.razorpayIntent(id: intentID) : 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 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 + 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(#"{"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.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 + 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/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/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/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/SpaceWorksTests/StaffTests.swift b/SpaceWorksTests/StaffTests.swift new file mode 100644 index 0000000..7ce8eeb --- /dev/null +++ b/SpaceWorksTests/StaffTests.swift @@ -0,0 +1,419 @@ +import Foundation +import Observation +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) + } +} + +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 { + 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 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: []) + 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 }) + 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) + 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) + 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) + 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) + 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 { + 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) + 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) + 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/AccountFlowTests.swift b/SpaceWorksUITests/AccountFlowTests.swift new file mode 100644 index 0000000..b2126fa --- /dev/null +++ b/SpaceWorksUITests/AccountFlowTests.swift @@ -0,0 +1,285 @@ +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 + 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 { + 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"] + 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 + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + XCTAssertEqual(XCTWaiter.wait(for: [sidebarReady], timeout: 3), .completed) + sidebar.tap() + let opened = XCTNSPredicateExpectation(predicate: readiness, object: profile) + 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), ready=\(readiness.evaluate(with: profile)), frame=\(exists ? String(describing: profile.frame) : "unavailable")") + } + XCTAssertEqual(openedResult, .completed) + } + tap(profile, in: 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 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 + XCTAssertTrue(alert.waitForExistence(timeout: 5)) + XCTAssertTrue(alert.staticTexts[title].waitForExistence(timeout: 5)) + 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") + alert.buttons[label].tap() + XCTAssertTrue(alert.waitForNonExistence(timeout: 5)) + return + } + // 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 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(_ 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 + 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.") + } + 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"] + 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)) + 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"] + 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) + 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) + 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() { + 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) + 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"]) + 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)) + 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) + let systemSave = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Save to Files")).firstMatch + 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() { + 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") + dismissAccountKeyboard("account-membership-keyboard-done", in: app) + tap(app.buttons["account-leave-makerspace"], 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/SpaceWorksUITests/GuideFlowTests.swift b/SpaceWorksUITests/GuideFlowTests.swift new file mode 100644 index 0000000..82ee9ea --- /dev/null +++ b/SpaceWorksUITests/GuideFlowTests.swift @@ -0,0 +1,80 @@ +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.buttons["nav-\(name)"] + 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 + let sidebarReady = XCTNSPredicateExpectation(predicate: readiness, object: sidebar) + // 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: 10), .completed) + } + 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.matching(NSPredicate(format: "label == %@", "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.")).firstMatch.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["Electronics workbench"].waitForExistence(timeout: 5)) + 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.matching(NSPredicate(format: "label == %@", "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.")).firstMatch.exists) + capture("Staff guide and camera explanation") + XCTAssertFalse(app.alerts.firstMatch.exists) + } +} diff --git a/SpaceWorksUITests/MainFlowTests.swift b/SpaceWorksUITests/MainFlowTests.swift index 3a4a404..8553589 100644 --- a/SpaceWorksUITests/MainFlowTests.swift +++ b/SpaceWorksUITests/MainFlowTests.swift @@ -1,64 +1,221 @@ 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() + app.launchForDeviceProfile() 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\n") + 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.buttons["nav-\(title)"] + 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 + 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) } - 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 testInvalidURLAndServerFailureRemainReviewable() { + func testPasswordMakerspaceSwitchProfileAndSignOut() { + 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") + 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)) + attach(app, "Profile landscape") + app.buttons["Sign out"].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() { + let app = launch(extra: ["--mfa"]); connect(app); credentials(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() { 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() { - 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) + func testDarkModeLargestTextPortraitAndLandscape() { + let app = launch(extra: ["--ui-start-light", "--ui-start-portrait", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + app.buttons["appearance"].tap(); connect(app) + let scroll = app.scrollViews["sign-in-scroll"] + XCTAssertTrue(scroll.waitForExistence(timeout: 5)) + 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) + 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 = 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) + 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) + 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() + 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") + } + 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.launchForDeviceProfile() + 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) + XCTAssertLessThanOrEqual(app.secureTextFields["password"].frame.height, 80, "A single-line password field must not consume spare tablet height") + 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/SpaceWorksUITests/MemberActionsFlowTests.swift b/SpaceWorksUITests/MemberActionsFlowTests.swift new file mode 100644 index 0000000..2f792a5 --- /dev/null +++ b/SpaceWorksUITests/MemberActionsFlowTests.swift @@ -0,0 +1,341 @@ +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") } + if element.label == "Confirm renewal" { capture("Loan renewal before confirmation") } + element.tap() + } + private func tap(_ element: XCUIElement, in app: XCUIApplication) { + 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 { + let insideScroll = contains(element, scroll) + if element.isHittable && !insideScroll { activate(element); return } + 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) + 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. + if element.elementType == .switch, element.isHittable, + !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 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 + // its switch thumb; coordinates are only needed for clipped text fields. + activate(element); 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 + } + // 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. + // 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. + 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 { + 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) + } + } + 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 } + 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.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 } + 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, 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. + 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: gestureInset, dy: gestureInset) + } + /// 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. + // 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. + var distance = min(200, viewport.height * 0.4) + 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 for review and reveal-only landscape proof. + 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. + var x = viewport.midX + 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 { + 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)) + .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.launchForDeviceProfile() + 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) + 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.buttons["nav-\(name)"] + 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 + 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) + } + 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) + 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") + 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"] + NativeUIActions.tap(name, in: app, requireWholeFrame: true) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + 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() + // 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) + 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") + 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) + // 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") + } + 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(["--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) + 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") + 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) + 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 new file mode 100644 index 0000000..1d4e548 --- /dev/null +++ b/SpaceWorksUITests/MemberFlowTests.swift @@ -0,0 +1,86 @@ +import XCTest + +final class MemberFlowTests: XCTestCase { + override func setUp() { continueAfterFailure = false; XCUIDevice.shared.orientation = .portrait } + private func demo(_ argument: String? = nil) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["--ui-testing"] + (argument.map { [$0] } ?? []) + app.launchForDeviceProfile() + 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() + 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.buttons["nav-\(name)"] + 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 + 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() + } + func testMemberInventorySearchDetailAndInboxRead() { + let app = demo() + app.buttons["Browse inventory"].tap() + let search = app.textFields["inventory-search"] + XCTAssertTrue(search.waitForExistence(timeout: 5)); XCTAssertTrue(search.isHittable); search.tap(); search.typeText("multimeter\n") + 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") + if app.navigationBars.buttons["Inventory"].exists { app.navigationBars.buttons["Inventory"].tap() } + tab(app, "Inbox") + XCTAssertTrue(app.staticTexts["Your kit is ready"].waitForExistence(timeout: 5)) + let unread = app.segmentedControls.buttons["Unread"] + XCTAssertTrue(unread.isHittable); unread.tap() + app.buttons["Mark all as read"].tap() + XCTAssertTrue(app.staticTexts["Nothing here yet."].waitForExistence(timeout: 5)) + capture("Inbox confirmed read state") + app.buttons["Exit demo"].tap() + XCTAssertTrue(app.textFields["server-url"].exists) + } + func testInventoryFilteredEmptyCanRecover() { + let app = demo(); tab(app, "Inventory") + let search = app.textFields["inventory-search"] + XCTAssertTrue(search.waitForExistence(timeout: 5)); XCTAssertTrue(search.isHittable); search.tap(); search.typeText("nothing-matches\n") + XCTAssertTrue(app.staticTexts["No items match these filters"].waitForExistence(timeout: 5)) + app.buttons["Clear filters"].tap() + XCTAssertTrue(app.descendants(matching: .any)["product-1"].firstMatch.waitForExistence(timeout: 5)) + } + func testEmptyAndErrorDataDoNotBecomePopulatedSuccess() { + let empty = demo("--demo-empty"); tab(empty, "Inventory") + XCTAssertTrue(empty.staticTexts["No items yet"].waitForExistence(timeout: 5)); capture("Inventory empty") + empty.terminate() + let offline = demo("--demo-error"); tab(offline, "Inventory") + XCTAssertTrue(offline.staticTexts["Couldn't connect. Try again."].waitForExistence(timeout: 5)) + XCTAssertFalse(offline.descendants(matching: .any)["product-1"].firstMatch.exists) + XCTAssertTrue(offline.buttons["Try again"].exists); capture("Inventory retry error") + } + func testRevokedMembershipCardNeverShowsDemoQR() { + let app = demo("--demo-revoked") + let card = app.buttons["View membership card"] + for _ in 0..<6 where !card.isHittable { app.swipeUp() } + XCTAssertTrue(card.isHittable); card.tap() + XCTAssertTrue(app.staticTexts["Your membership card isn't active. Ask the front desk for help."].waitForExistence(timeout: 5)) + XCTAssertFalse(app.images["Simulated membership card QR code"].exists) + capture("Revoked membership card") + app.buttons["Done"].tap() + } + private func capture(_ name: String) { + let item = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); item.name = name; item.lifetime = .keepAlways; add(item) + } +} diff --git a/SpaceWorksUITests/NativeAuthFlowTests.swift b/SpaceWorksUITests/NativeAuthFlowTests.swift new file mode 100644 index 0000000..e1a1f91 --- /dev/null +++ b/SpaceWorksUITests/NativeAuthFlowTests.swift @@ -0,0 +1,138 @@ +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) + attach("Phone verification code") + // A complete six-digit code verifies automatically, like a code filled from Messages. + code.typeText("123456") + 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)) + } + + 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(); 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 { + 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 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\n") + attach("Deletion confirmation") + NativeUIActions.tap(submit, in: app) + XCTAssertTrue(app.textFields["username"].waitForExistence(timeout: 10)) + } + + 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) + 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) + } +} diff --git a/SpaceWorksUITests/NativeTestLaunch.swift b/SpaceWorksUITests/NativeTestLaunch.swift new file mode 100644 index 0000000..7a7a4f1 --- /dev/null +++ b/SpaceWorksUITests/NativeTestLaunch.swift @@ -0,0 +1,76 @@ +import XCTest +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 + lastFrame = self.frame + return lastFrame.width > lastFrame.height + }, object: nil) + let result = XCTWaiter.wait(for: [orientation], timeout: timeout) + 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" + 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" + launchEnvironment["SPACEWORKS_TEST_APPEARANCE"] = dark ? "dark" : "light" + launchEnvironment["SPACEWORKS_TEST_REDUCE_MOTION"] = reduceMotion ? "1" : "0" + 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) + 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.") + 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/NativeUIActions.swift b/SpaceWorksUITests/NativeUIActions.swift new file mode 100644 index 0000000..db613b0 --- /dev/null +++ b/SpaceWorksUITests/NativeUIActions.swift @@ -0,0 +1,175 @@ +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 { + let scroll = containingScroll(element, app) + if element.exists { + let insideScroll = contains(element, scroll) + 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 !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. + 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 = 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 !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 + } + // 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 + // 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 { + 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) + } + } + 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 { 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 } + 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, 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. + 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: gestureInset, dy: gestureInset) + } + 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. + // 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. + 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. + var x = viewport.midX + 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 { + 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)) + .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..ff52e69 --- /dev/null +++ b/SpaceWorksUITests/NotificationsFlowTests.swift @@ -0,0 +1,87 @@ +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.buttons["nav-\(name)"] + 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 + 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) + } + 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") + 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 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) + 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) + 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.") + 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) + 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) + 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 new file mode 100644 index 0000000..229d2a7 --- /dev/null +++ b/SpaceWorksUITests/PaymentsFlowTests.swift @@ -0,0 +1,130 @@ +import XCTest +import Vision + +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.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); 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.buttons["nav-Profile"] + 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 + 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) + } + 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 + // 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.") + } + private func chooseDemoOutcome(_ title: String, in app: XCUIApplication) { + // 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() + } + 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 { 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. + 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) + 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.") + } + 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); 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") + 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)) + waitForRenderedReceipt(in: app) + 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); 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/SpaceWorksUITests/SecurityFlowTests.swift b/SpaceWorksUITests/SecurityFlowTests.swift new file mode 100644 index 0000000..2493675 --- /dev/null +++ b/SpaceWorksUITests/SecurityFlowTests.swift @@ -0,0 +1,50 @@ +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.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) + 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") + 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) + 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.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") + 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/SpaceWorksUITests/StaffFlowTests.swift b/SpaceWorksUITests/StaffFlowTests.swift new file mode 100644 index 0000000..ab09a45 --- /dev/null +++ b/SpaceWorksUITests/StaffFlowTests.swift @@ -0,0 +1,96 @@ +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.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) + 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.buttons["nav-Profile"] + 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 + 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) + } + 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. + 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) + } + 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) + 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) + 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) + // 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) + 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") + } + 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/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 b72e3e2..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 include the API prefix, so clients use a validated deployment origin. A0 generates health and device-refresh operations plus their dependent models." + "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/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 bdabc43..ef83b1d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,687 @@ # 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. + +**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. + +**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. + +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] 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) + +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. + +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. + +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. + +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. + +## 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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +CI now compiles the entire scheme, runs every native state/API test, then runs every UI journey as distinct steps. Native tests have a ten-minute step bound so an unreachable fixture wait is visible promptly; both result bundles and the shared build log are archived. Scheme, destinations, signing flags, all tests, six profiles and the 120-minute job limit are retained. + +This isolated A8 candidate started from A7 `d05dcfc477dae19bd2acd78201f783c9d27cfd61` and now includes the `8ff47bc` staff-title compiler correction through a normal merge. The prior [A7 workflow 39018648885](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/39018648885) was canceled for diagnostic logs; native acceptance at corrected dev remains pending; 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, +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 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. +- 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. +- 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. + +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. + +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 +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 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 +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. + +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 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 +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. +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. + +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. + +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) + +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; +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. + +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 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 +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 +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 +again before a refresh retry; invalidate protected screen state before changing scope. Pagination +validates origin and API namespace, follows the returned page number and deduplicates results. + +Home has loan/due/overdue summaries, bookings, events, notices and currency-preserving dues. Missing +dues remain unavailable, never a fabricated zero. Inventory has search, category filters, pagination, +borrowing terms and a regular-width list/detail layout. Phone tabs and iPad sidebar share the same +destinations; readable dashboard cards adapt to width and accessibility text size. Inbox read actions +refetch confirmed server state and unread badges. Cards clear QR content before loading and on any +refusal; invalidation and request generations prevent late responses from restoring a revoked QR. + +The isolated Debug member repository uses synthetic generated DTO fixtures, with populated, empty, +error and revoked-card scenarios. It has no network, token or server-setting writes. Contextual Home +tips can be dismissed and replayed; live preference scope includes server, account and makerspace. +Rendered tablet review also found a UIKit single-line password field stretching into spare vertical +space. Give it an intrinsic fitting height that follows Dynamic Type, and assert its normal-size +height in the existing focus/visibility journey. +Runtime tablet validation passes all 71 native tests and exposed a portrait sidebar overlay covering +selected destinations. Close that overlay after selection while keeping the wide sidebar visible; +assert real detail-control hit testing in the member journeys. Add a synchronous opaque scene +privacy window above presented sheets before app-switcher snapshots, preserving drafts and focus +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. 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. +Add native file/photo/scanner support for later phases with bounded transfers, credential-free signed +storage requests, QR camera fallback and metadata-stripping JPEG preparation. These helpers do not +claim that later staff or payment flows are wired or live verified. + +Parity: Welcome back, {name}.; Active loans / Upcoming bookings / Events / Membership card / Notices; +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. +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) + +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. + +Completion evidence: [`4b5dbff` CI38933144741](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS/actions/runs/38933144741) passes50 native XCTest cases and8 UI journeys on both phone and iPad,14 Python checks/lint, and the unsigned physical-device Release build. Tablet introduction, recovery, normal sign-in and largest-text dark landscape screenshots were rendered and reviewed. Landscape evidence uses full-screen capture plus a real landscape frame assertion to avoid cropped app-screenshot orientation artifacts. Review found the single-line password-height correction recorded in A2 above; its new regression assertion runs in that checkpoint. + +## 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 +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 supplies the native compiler and +runtime evidence below. + +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. +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. + +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. +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`. + +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. @@ -8,7 +690,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 +702,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. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md new file mode 100644 index 0000000..6644c2f --- /dev/null +++ b/docs/IMPLEMENTATION-PLAN.md @@ -0,0 +1,59 @@ +# 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. Under the owner's latest delivery instruction, consolidate work on `dev` +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 | +|---|---|---| +| 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 | +| 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. + +The owner's additional UX checkpoint implements the mobile-local `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. + +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/LOCAL-DOCKER-VALIDATION.md b/docs/LOCAL-DOCKER-VALIDATION.md new file mode 100644 index 0000000..52b2e89 --- /dev/null +++ b/docs/LOCAL-DOCKER-VALIDATION.md @@ -0,0 +1,49 @@ +# 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. + +| 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/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/docs/NATIVE-SDK-SETUP.md b/docs/NATIVE-SDK-SETUP.md new file mode 100644 index 0000000..3761db4 --- /dev/null +++ b/docs/NATIVE-SDK-SETUP.md @@ -0,0 +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. 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, `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. + +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), +[RFC 8252](https://www.rfc-editor.org/rfc/rfc8252). diff --git a/docs/PUSH-NOTIFICATIONS.md b/docs/PUSH-NOTIFICATIONS.md new file mode 100644 index 0000000..3b6333c --- /dev/null +++ b/docs/PUSH-NOTIFICATIONS.md @@ -0,0 +1,99 @@ +# 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. + +## 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 +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/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md new file mode 100644 index 0000000..1da41f0 --- /dev/null +++ b/docs/RELEASE-READINESS.md @@ -0,0 +1,162 @@ +# 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. 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 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 + +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/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 +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 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 + +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. 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, +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 | 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 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 | 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 new file mode 100644 index 0000000..a913f6f --- /dev/null +++ b/docs/STORE-LISTING.md @@ -0,0 +1,115 @@ +# App Store listing draft + +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 + +| 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 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. +``` + +## 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 repository delivery ledger links the accepted +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 + +The in-app **Explore demo** is a Debug-only, in-memory preview. Its sample credentials do not +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 | +|---|---| +| 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..008b410 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, 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`: + +- `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. + +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. + +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. -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, 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/images/inventory.png b/docs/images/inventory.png new file mode 100644 index 0000000..1aa0d11 Binary files /dev/null and b/docs/images/inventory.png differ diff --git a/docs/images/ipad-profile.png b/docs/images/ipad-profile.png new file mode 100644 index 0000000..2a4ea32 Binary files /dev/null and b/docs/images/ipad-profile.png differ diff --git a/docs/images/ipad-sign-in.png b/docs/images/ipad-sign-in.png new file mode 100644 index 0000000..7220f46 Binary files /dev/null and b/docs/images/ipad-sign-in.png differ diff --git a/docs/images/onboarding.png b/docs/images/onboarding.png new file mode 100644 index 0000000..8651e63 Binary files /dev/null and b/docs/images/onboarding.png differ 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 0000000..429746f Binary files /dev/null and b/docs/images/request-review.png differ diff --git a/docs/images/spaceworks-icon.png b/docs/images/spaceworks-icon.png new file mode 100644 index 0000000..6ab4982 Binary files /dev/null and b/docs/images/spaceworks-icon.png differ diff --git a/docs/mobile-auth-ux-spec.md b/docs/mobile-auth-ux-spec.md new file mode 100644 index 0000000..ad7c090 --- /dev/null +++ b/docs/mobile-auth-ux-spec.md @@ -0,0 +1,301 @@ +# Space Works mobile: onboarding, authentication, and first use + +Implementation specification, 16 October 2026. Maintained in the mobile repositories at the owner's request; the Design submodule supplies existing brand assets only. Applies to the native Android and iOS apps, including tablets and resizable windows. + +## 1. Delivery and authority + +The owner requested a complete, polished onboarding/authentication experience in the Space Works brand, including research and implementation in both apps. Implement this as a dedicated UX/onboarding checkpoint after the current A1 checkpoint passes; preserve the already frozen A1 verification while that work finishes. This document is an implementation input, not evidence that the apps implement it. Each platform must deliver the screens, state transitions, tests, and screenshot evidence below before declaring the UX checkpoint complete. + +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 `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. + +## 2. Shared visual language + +Use a warm, quiet canvas, flat bordered panels, clear labels, and one primary action per task. Keep the brand recognizable through the exact masters, colors, spacing, and voice rather than decorative effects. Use native SF Symbols/Material icons consistently at a 24-unit drawing size, with labels for unfamiliar actions. Do not generate raster art or redraw the logo. Product illustrations may be simple native vector cards, tool silhouettes, calendar tiles, or a loan list, clearly illustrative rather than purported live activity. + +### Color roles + +Generate theme values from the JSON; this table records the current values for review and does not create a second token source. Some older prose in the brand document has stale dark colors. + +| Role | Light | Dark | Application | +|---|---|---|---| +| `bg` | `#FFF4E8` | `#142C2A` | Whole canvas | +| `surface` | `#F6ECE0` | `#193935` | Quiet supporting regions | +| `panel` | `#FFFFFF` | `#1F433F` | Form and task cards | +| `ink` | `#142C2A` | `#FFF4E8` | Main copy | +| `muted` | `#5B6863` | `#C7C4BA` | Supporting copy | +| `line` | `#D5D0C6` | `#4D6D67` | Decorative dividers | +| `outline` | `#828A83` | `#93A198` | Input boundaries | +| `accent` / `on-accent` | `#006E63` / `#FFF4E8` | Same pair | Filled primary buttons | +| `accent-ink` | `#006E63` | `#ABC8BC` | Links and textual accents | +| `focus` | `#006E63` | `#DFFF8F` | Keyboard and accessibility focus | +| `danger` | `#B91C1C` | `#FCA5A5` | Error text and destructive actions | +| `success-soft` / `success-ink` | `#FBFFF2` / `#536D49` | `#2C5045` / `#DFFF8F` | Success callouts with a word/icon | + +Dark `accent` on the dark canvas is not an accessible text pair; use `accent-ink` for links. Never use orange text on cream. Status always has a word or icon as well as color. Keep Light and Dark as the two explicit theme choices, matching the existing Design owner rule; persist the preference. Tenant branding must use the approved derived theme and preserve contrast, not overwrite semantic danger/success colors. + +Use a 4-unit spacing grid: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64. Inputs and buttons have radius 10; cards 16. Cards have a 1-unit border and no shadow. Use 48-unit minimum control/touch dimensions on both platforms (this also exceeds the iOS 44pt floor). Inline text links have a sufficiently padded hit region without making adjacent targets overlap. Body text defaults to the platform's readable body size, scales with accessibility settings, and is never shrunk to preserve a layout. Headings are semibold/bold system type, not all-caps marketing copy. Codes and IDs use the UI family with tabular digits, not a new monospace family. + +### Logo application and correction + +Use the supplied vector/master artwork with its intrinsic aspect ratio and its existing internal symbol/wordmark geometry. Artwork on cream is full color; on white use the one-colour ink master; on Ink or Teal use the reversed cream master. A logo is one accessibility element labelled โ€œSpace Worksโ€; hide decorative duplicates from accessibility. + +| Placement | Exact practical choice | Clear space outside artwork | +|---|---|---| +| Phone header, narrow task screen | Dedicated `symbol` master at height 48, intrinsic width about 51.8 | 12 on every side; header region at least 72 high | +| Phone welcome/sign-in, normal available height | `lockup-stacked` master at width 180, intrinsic height 64 | 16 on every side | +| Phone landscape/keyboard/large text | Switch to symbol header above; do not shrink the master | 12 on every side | +| Spacious tablet auth brand panel | Stacked master width 240โ€“320, intrinsic height about 85โ€“114 | One quarter of symbol height on every side, about 22โ€“29 | +| Wide horizontal header, only if space permits | `lockup-horizontal` at height at least 48; supplied 360ร—58 master needs width about 298 at that height | At least 12 on every side | +| Installed app icon / OS launch icon | Supplied `app-icon` artwork, respecting each platform's icon masks and launch API | Use the supplied tile; do not create a replacement tile inside the sign-in form | + +The stacked primary lockup minimum is 180 wide; the symbol minimum is 48 high. A horizontal lockup squeezed into 180ร—64 is only about 29 high and fails the symbol minimum. Fix the current iOS compact auth wordmark choice accordingly. Android auth should use the dedicated symbol/lockup, not the rounded app-icon tile. The masters are outlined; they do not require a licensed font to render. + +The master and every containing view that visually affects it stay static and fully opaque. No transform, scale, rotation, translation, fill override, filter, opacity fade, mask reveal, shadow, spring bounce, stretching, or panel recolor. Animate sibling content only. This includes transitions: do not put the logo inside an animated/fading page container. The OS-controlled splash behavior is platform-owned; do not add a second animated logo splash in app code. + +## 3. Entry flow and persistence + +The state graph is: OS launch โ†’ resolve local settings/session โ†’ optional first-run introduction โ†’ server selection/health check โ†’ configured sign-in โ†’ required authentication gates โ†’ makerspace selection โ†’ Home. A valid returning session bypasses the introduction and sign-in. A pending protected deep link is retained as an opaque intent and resolved only after authentication, current membership, and fresh API authorization. Never show protected content for a frame while restoring credentials. + +Persist introduction completion/skip per installation, theme choice, validated server origin, and last valid makerspace selection under the session's account/server scope. Logout does not repeat the product introduction. Help โ†’ โ€œShow introductionโ€ replays it. Do not persist passwords, OTPs, MFA setup secrets, recovery codes, provider ID tokens, or pending secret reset links in ordinary preferences, state restoration, logs, screenshots, or analytics. Secure token storage follows the PRD. Account/server changes clear all account-scoped navigation, cached data, carts, QR codes, and pending operations before loading another account. Do not offer multiple stored accounts unless secure multi-account storage exists; โ€œUse another accountโ€ can sign out the current account and start fresh. + +### Launch and introduction + +Keep OS launch artwork static. Restore state immediately; do not impose a minimum splash duration or wait for an animation. If restoration needs visible time, show a quiet content loading state below the static brand header. Keep โ€œChange serverโ€ available after a recoverable server failure. First run does not request notifications, camera, location, photo-library access, or payment setup. + +The optional introduction has three manually advanced pages, with visible โ€œSkip introductionโ€ throughout, Back after page one, and no automatic paging. Show a readable โ€œ1 of 3โ€ equivalent to assistive technology; page dots alone are insufficient. Swiping is optional, never the only navigation. Keep controls in stable positions while copy wraps. + +| Page | Heading | Supporting copy | Illustration | Primary action | +|---|---|---|---|---| +| 1 | Make room. | Find tools, book a space, and see what's next at your makerspace. | Quiet vector tool/shelf cards | Next | +| 2 | Borrow with confidence. | Request what you need and keep an eye on your loans and due dates. | Illustrative request and due-date cards | Next | +| 3 | One place for your making. | Keep bookings, event tickets, payments, and updates together. | Simple calendar, ticket, and inbox tiles | Get started | + +Use a citron or orange accent in one illustrative region per page; keep text on tested ink/neutral pairs. Illustrative cards must not show a fabricated user name, unread count, transaction, loan, or membership. Start useful content without onboarding if the owner build disables the introduction. Store permission explanations are contextual later in the journey. This follows Apple's recommendation for short, optional onboarding and contextual guidance: [Apple onboarding](https://developer.apple.com/design/human-interface-guidelines/onboarding). + +## 4. Screen and state specification + +Every task screen uses the same static brand header, a heading, one short explanation, a bounded form/task card, its primary action, and secondary navigation. Provider buttons appear before password fields when available; show โ€œor sign in with a passwordโ€ only when both methods are actually available. No Member/Staff role selector: server authority determines access after sign-in. + +| Screen/state | Copy and controls | Transition and guard | +|---|---|---| +| Server selection | โ€œConnect to your makerspaceโ€; โ€œEnter the server address provided by your makerspace.โ€ Label โ€œServer addressโ€; primary โ€œConnectโ€; supporting current white-label default if supplied | Parse/validate the PRD URL rules, run the generated health/config flow, and save only a successful normalized origin. Permit only the PRD's explicit debug exception to HTTPS. Do not silently switch origins or reuse another origin's credentials. | +| Server loading/failure | โ€œConnectingโ€ฆโ€; then โ€œCouldn't connect to this server. Check the address and try again.โ€ Actions โ€œTry againโ€, โ€œEdit addressโ€ | Keep entered address and keyboard position; failures do not become a healthy saved server. Config failure is a retry state, not guessed provider availability. | +| Sign in | โ€œWelcome back.โ€; โ€œSign in to your makerspace account.โ€ Labels โ€œEmail or usernameโ€, โ€œPasswordโ€; โ€œForgot password?โ€ on password-label row; primary โ€œSign inโ€; footer โ€œNew here?โ€ โ†’ โ€œCreate accountโ€ only when self-registration is allowed | Password method respects config. Username may be a username; do not force email syntax. Show chosen server name/origin and โ€œChange serverโ€. No secret body/error response is printed in UI. | +| Sign-in submitting | โ€œSigning inโ€ฆโ€ in the same button bounds, accessible progress announcement | Disable duplicate submissions and conflicting provider/account/server actions while the atomic native grant is being obtained/stored. A cancel/back action must have safe cleanup; stale callbacks cannot navigate or write credentials. | +| No available sign-in method | โ€œSign-in isn't available hereโ€; โ€œAsk your makerspace for help, or check the server address.โ€ Actions โ€œChange serverโ€, โ€œTry againโ€ | Do not render pretend enabled providers or a browser cookie workaround. | +| Create account | โ€œCreate your accountโ€; โ€œUse the email you want to use with your makerspace.โ€ Labels โ€œNameโ€, โ€œEmailโ€, โ€œPasswordโ€, โ€œConfirm passwordโ€; optional โ€œPhone number (optional)โ€ under disclosure; primary โ€œCreate accountโ€; secondary โ€œAlready have an account? Sign inโ€ | Generated `MemberSignUp`. Do not ask users for role, makerspace admin access, or website. `website` remains the schema's empty default/honeypot. Success is acknowledgement, not an authenticated session. | +| Signup acknowledgement | โ€œAccount createdโ€; โ€œSign in to continue. You may need to confirm your email.โ€ Primary โ€œGo to sign inโ€ | Preserve email as a non-secret suggestion, clear passwords, then use normal native login. Do not turn the acknowledgement into access/refresh tokens. Do not promise email delivery if it is disabled. | +| Confirm email | โ€œConfirm your emailโ€; โ€œEnter the six-digit code sent to your email.โ€ Label โ€œVerification codeโ€; primary โ€œConfirm emailโ€; โ€œSend another codeโ€; โ€œUse another accountโ€ | Only in an authenticated/restricted verification gate supported by server state. Show actual account email if known, not guessed. Re-fetch account after success; a restricted session does not unlock normal tabs. Handle disabled email delivery with help copy rather than a fake send. | +| Forgot password: request | โ€œReset passwordโ€; โ€œEnter your account email to request a six-digit recovery code.โ€ Label โ€œEmailโ€; primary โ€œRequest recovery codeโ€; โ€œBack to sign inโ€ | Generated forgot-password. Acknowledgement is uniform regardless of account existence. | +| Forgot password: confirm | โ€œEnter your recovery codeโ€; โ€œIf an account matches this email, a recovery code will arrive. Delivery may take a few minutes.โ€ Fields โ€œSix-digit codeโ€, โ€œNew passwordโ€, โ€œConfirm passwordโ€; primary โ€œUpdate passwordโ€; โ€œRequest another codeโ€; โ€œChange emailโ€ | Six digits plus new-password checks. Resend uses the same generated request. All reset-confirm server rejections use the identical message below, regardless of whether OTP, account, or policy caused rejection. | +| Reset rejection | โ€œInvalid or expired verification code.โ€ | Do not reveal server password-policy details conditionally after a valid OTP. Local checks independent of server validity may still explain minimum length/mismatch. Preserve email/new-password editing state in memory; never log submitted code/password. | +| Reset success | โ€œPassword updatedโ€; โ€œYour password has been updated. You can now sign in.โ€ Primary โ€œGo to sign inโ€ | Clear code and passwords, return to native sign-in. Reset response does not establish a session. | +| Recovery unavailable | โ€œPassword recovery unavailableโ€; โ€œContact your makerspace staff to reset your password.โ€ Primary โ€œBack to sign inโ€ | Only a known `recovery_unavailable` response enters this state; a transient failure stays retryable. | +| Temporary-password gate | โ€œChoose a new passwordโ€; โ€œReplace your temporary password to continue.โ€ Fields โ€œCurrent passwordโ€, โ€œNew passwordโ€, โ€œConfirm passwordโ€; primary โ€œUpdate passwordโ€; secondary โ€œSign outโ€ | Block app tabs until generated change-password succeeds and refreshed `/auth/me` confirms the gate is cleared. Response is detail only; do not invent tokens. Never prefill the old password from saved plaintext. | +| MFA enrollment | โ€œAdd extra securityโ€; โ€œAdd this account to your authenticator app, then enter its six-digit code.โ€ Server-provided setup QR; โ€œCan't scan the code?โ€ reveals the setup key with deliberate copy action; primary โ€œVerify and continueโ€ | Enrollment challenge is not an authenticated session. QR/setup key are secrets, shown only while this screen is active. No automatic external URL launch from QR content. Manual key is essential for single-device use. | +| MFA verification | โ€œCheck your authenticatorโ€; โ€œEnter the six-digit code from your authenticator app.โ€ Label โ€œAuthentication codeโ€; primary โ€œVerifyโ€; secondary โ€œUse a recovery codeโ€; โ€œBack to sign inโ€ | Submit only for the active unexpired challenge; prevent overlapping verification/enrollment tasks. Do not request SMS or label authenticator verification as an email code. | +| MFA recovery entry | โ€œUse a recovery codeโ€; โ€œEnter one of the recovery codes you saved when you set up extra security.โ€ Label โ€œRecovery codeโ€; primary โ€œVerifyโ€; secondary โ€œUse authenticator insteadโ€ | Accept the actual server format up to the schema's 64-character maximum; do not restrict this field to six digits or silently strip punctuation. Same active challenge; code use is server-authoritative. | +| MFA challenge expired | โ€œSign in againโ€; โ€œYour verification session expired. Sign in to request a new one.โ€ Primary โ€œBack to sign inโ€ | Clear challenge/setup secret/code/recovery list and pending SDK results; do not retry it via the access-token refresher. A background/resume recomputes expiry from server timing. | +| MFA recovery codes | โ€œSave your recovery codesโ€; โ€œKeep these somewhere safe. You can use each code once if you lose access to your authenticator.โ€ Deliberate copy/export if implemented securely; checkbox โ€œI've saved my recovery codesโ€; primary โ€œContinueโ€ | Display only real returned codes, not a fabricated list. Require acknowledgement. Do not silently store in preferences or copy without action. Explain exported files contain account access secrets. Clear view state on exit. | +| Security settings | โ€œSign-in and securityโ€; show actual password/MFA/linked-provider status; actions โ€œChange passwordโ€, โ€œSet up extra securityโ€, โ€œGet new recovery codesโ€, โ€œTurn off extra securityโ€, and supported link/unlink methods | These are distinct authenticated server flows, not local switches. Reauthentication uses the exact supported method; password-only protected operations cannot accept an invented social proof. Explain that regenerating codes replaces the old set; confirm before disable/unlink. A server refusal to remove the last sign-in method stays a refusal. Phone verification/linking is not automatically native phone login availability. | +| Select makerspace: several | โ€œChoose your makerspaceโ€; cards with name and permitted membership label; primary on selected row โ€œContinueโ€; optional search when list is long | Use eligible memberships from current account response; organization-only authority does not confer mobile staff access. Scope all subsequent requests to the chosen space. | +| Select makerspace: one | Load that eligible space directly | Do not make returning users reconfirm a single choice; offer Profile โ†’ โ€œSwitch makerspaceโ€ if multiple become available. | +| No eligible makerspace | โ€œNo makerspace access yetโ€; โ€œAsk your makerspace to add or reactivate your membership.โ€ Actions โ€œRefresh accessโ€, โ€œUse another accountโ€, โ€œSign outโ€ | An authenticated account without authorized membership must not see another account's cache or an invented join success. Offer an actual supported invitation/join action only when the real flow exists. | +| Session revoked/expired | โ€œSign in againโ€; โ€œYour session ended. Sign in to continue.โ€ Primary โ€œSign inโ€ | Wipe the invalid local session and protected caches. Preserve server preference; discard protected pending mutations. Reauthorize pending links after sign-in. Do not call a network timeout a revoked session. | +| Superadmin refusal | โ€œUse the web consoleโ€; โ€œThis account needs to use the web console.โ€ Primary โ€œBack to sign inโ€ | Show the PRD refusal only on the server refusal/known authority condition, not guessed role strings. Open only a configured trusted console URL if a secondary web action is supplied. | +| Minimum-version gate | โ€œUpdate to continueโ€; โ€œThis version of the app needs an update to connect.โ€ Primary โ€œUpdate appโ€ | Only from authoritative version configuration when available; trusted store URL, no arbitrary server-provided executable. Present fully in demo; do not invent a production config field. | +| Sign out | Confirmation title โ€œSign out?โ€; โ€œYou'll need to sign in again to use this account.โ€ Primary destructive โ€œSign outโ€, secondary โ€œCancelโ€ | Revoke via supported generated flow when possible; always clear local access on explicit sign-out, including offline. Stop device/token association attempts. Clear provider credential state and caches. Do not claim all other devices were signed out. | +| Use another account/server | Explain that continuing signs out this account; โ€œContinueโ€, โ€œCancelโ€ | Complete the same local cleanup before returning to sign-in/server. Do not save the old password or silently select the prior Google account. | + +Do not add terms/privacy acceptance checkboxes unless the configured legal flow requires them. Display configured legal links as ordinary accessible links; do not invent legal promises or placeholder URLs. Display โ€œSpace Worksโ€ in prose, and use a configured product/makerspace name only where white-label configuration explicitly supplies it. + +## 5. Fields, focus, keyboards, and submission + +Use real labelled native fields, not a drawing of a text field. Labels remain visible after entry; examples are placeholders, not labels. Helpers and errors belong beneath their field and are programmatically associated. On submit, announce the first invalid field and focus/scroll it into view without repeatedly stealing focus while typing. Validate required fields on submit and invalid touched fields on blur; do not show a page of errors on first appearance. Preserve correct entries after a failure. Errors have a word/icon and color, never color alone. + +| Input | Native behavior | Rules | +|---|---|---| +| Username/email login | No capitalization/autocorrect; username/password-manager semantics; Next โ†’ password | Username is not always an email. Trim surrounding identifier whitespace only if consistent with the generated gateway/server normalization. Never trim password. | +| Signup/recovery email | Email keyboard, email content type, no capitalization/autocorrect | Email syntax, not account-existence probing. Signup max254 per schema. | +| Name | Name autofill, words keyboard | Required signup, max200; do not reject non-Latin names or require two words. | +| Current/login password | Password autofill, secure entry, no autocorrect/capitalization | Preserve exact spaces and Unicode; allow paste and password managers. Return key submits only when all local required data is valid. | +| New/confirm password | New-password autofill/generation semantics for both fields | Match frontend's local minimum8 and confirmation equality. That minimum is an existing UI policy, not an invented schema constraint or guarantee of server acceptance. Signup max128. Keep any additional server policy server-authoritative. No arbitrary mandatory character classes or misleading strength meter. | +| Email/SMS reset verification code | Numeric keyboard plus one-time-code semantics | One real six-character field styled as desired, not six separately focusable traps; paste full code. Normalize ASCII digit input consistently; explicit verify action. Never submit repeatedly just because six characters were entered. | +| Authenticator code | Numeric keyboard, six-digit local validation | Keep distinct from SMS/email autofill expectations; explicit verify. | +| MFA recovery code | Text keyboard, no autocorrect/capitalization | Allow full server code format; one field; no numeric-only filter. | +| Server origin | URL keyboard, no autocorrect/capitalization | Show validation inline; don't send credentials until origin is validated. | + +Show/hide password is a real 48-unit target inside the field boundary, labelled โ€œShow passwordโ€ / โ€œHide passwordโ€ with announced state. Toggling preserves value, focus, selection, autofill association, and keyboard; it does not submit or clear the field. Do not toggle all password fields together unexpectedly. Error state must not replace the visibility button or overlap entered text. Text remains legible with large font settings. Keep the submit button above the keyboard or reachable by scrolling; no fixed bottom overlay covering final fields. + +Android uses Compose `ContentType.Username` (combine `EmailAddress` when appropriate), `Password` and `NewPassword` semantics. iOS uses native `.username`, `.password`, `.newPassword`, `.emailAddress`, `.oneTimeCode` where appropriate and an implementation that preserves text/focus during secure-entry changes. Verify these behaviors with a real password manager/device; semantic unit tests alone cannot prove OS autofill. See [Android autofill](https://developer.android.com/develop/ui/compose/text/autofill) and [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-a-text-input-view). + +One submission owns one visible operation. Show busy state in the original control bounds and prevent duplicate side effects. Give cancellation neutral treatment (โ€œSign-in cancelledโ€ only if explanation is useful); do not show an error alert for dismissing an OS provider sheet. Lifecycle recreation must not launch duplicate providers, issue duplicate payments, or apply stale callbacks to a different account/server. Provider success is not app sign-in success until the server grant is validated and safely stored. + +## 6. Provider availability and native SDK behavior + +Provider order is configured Google, configured Apple on its supported platform, configured enterprise OIDC, then phone if actually supported; don't show empty slots. Only advertise a provider when both its SDK/setup inputs and its generated native exchange are ready. A configured web provider alone does not establish a valid native session. Show a quiet โ€œMore sign-in optionsโ€ disclosure if needed rather than five competing primary actions. Password remains available if allowed by configuration; missing optional password flag matches the frontend's enabled default, but failed config loading is not the same as an absent flag. + +| Provider | Implementation and UX requirement | Production limit in the pinned contract | +|---|---|---| +| 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 | 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/). + +### Android Google SDK checkpoint + +Verified 16 October 2026 against official release notes: `androidx.credentials:credentials:1.6.0`, `androidx.credentials:credentials-play-services-auth:1.6.0`, and `com.google.android.libraries.identity.googleid:googleid:1.2.1`. Use matching stable Credentials artifacts, not the alpha version shown in some current guide examples. Check the app's build constraints when adding them; version verification does not replace compiling and exercising the integration. Sources: [AndroidX releases](https://developer.android.com/jetpack/androidx/releases/credentials), [Google ID releases](https://developers.google.com/identity/android-credential-manager/releases). + +Manual button flow: `CredentialManager.create(context)` โ†’ `GetSignInWithGoogleOption.Builder(serverClientId = WEB_CLIENT_ID).setNonce(serverNonce).build()` โ†’ `GetCredentialRequest.Builder().addCredentialOption(option).build()` โ†’ `getCredential` with the current foreground Activity context. The current official guide recommends a `MutableContextWrapper` around that Activity for reconstruction handling. The [Google option builder](https://developers.google.com/identity/android-credential-manager/android/reference/com/google/android/libraries/identity/googleid/GetSignInWithGoogleOption.Builder) defines the server audience and nonce. Accept only a `CustomCredential` with `GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL`, parse with `GoogleIdTokenCredential.createFrom(data)`, then pass `.idToken` to the existing generated gateway; see [Google credential reference](https://developers.google.com/identity/android-credential-manager/android/reference/com/google/android/libraries/identity/googleid/GoogleIdTokenCredential). Use the server nonce required by this app's contract instead of creating an unrelated nonce locally. Never log the token. + +An optional returning-account bottom sheet uses `GetGoogleIdOption` with authorized-account filtering first; `NoCredentialException` may fall back to all accounts. Do not auto-open a provider chooser on cold launch or fight the user's explicit account switch. Clear credential state on explicit sign-out with `ClearCredentialStateRequest`; that clears app selection state rather than revoking the user's Google permissions. SDK cancellation, parsing failures, unavailable Play services, and a rejected native grant need separate tested outcomes. [Official implementation guide](https://developer.android.com/identity/sign-in/credential-manager-siwg-implementation). + +## 7. Generated authentication operations and exact shapes + +Use the generated operation/model from the pinned contract; names differ between Swift/Kotlin generators but the OpenAPI operation IDs below are authoritative. Extra confirmation fields exist only in UI state and must not be transmitted. + +| Operation ID (path relative to `/api/v1`) | Exact request | Result and UI implication | +|---|---|---| +| `api_v1_auth_member_sign_up_create` (`POST /auth/member-sign-up`) | `MemberSignUp`: required `display_name`, `email`, `password`; optional `phone` default empty max32, `website` default empty | `MemberVerificationAck(detail)`, not tokens. Native login follows acknowledgement. | +| `api_v1_auth_forgot_password_create` (`POST /auth/forgot-password`) | `ForgotPasswordRequest(email)` | `PasswordResetAcknowledgement(detail)`; uniform acknowledgement, then confirm screen. | +| `api_v1_auth_reset_password_create` (`POST /auth/reset-password`) | `ResetPasswordConfirmRequest` union: `OtpResetPasswordConfirm(email, code, new_password)` with code pattern `^\d{6}$`, or `LegacyResetPasswordConfirm(uid, token, new_password)` | `PasswordUpdated(detail)`; return to native sign-in. Never send both union branches. Legacy link support uses only a validated supported link; query values are secrets and never an auto-submit. | +| `api_v1_auth_email_verification_confirm_create` (`POST /auth/email-verification/confirm`) | Authenticated `EmailVerificationConfirm(code)`, max6; UI uses six digits | Confirm then reload current account/gates. | +| `api_v1_auth_email_verification_resend_create` (`POST /auth/email-verification/resend`) | Authenticated operation, no invented JSON body | Acknowledgement and resend cooldown. | +| `api_v1_auth_change_password_create` (`POST /auth/change-password`) | `ChangePassword(current_password, new_password)` | Detail only; re-fetch account, preserve actual gate until server clears it. | +| `api_v1_auth_mfa_enroll_create` (`POST /auth/mfa/enroll`) | `MFAChallenge(challenge)` | Setup uses server secret/URI; not app access. | +| `api_v1_auth_mfa_confirm_create` (`POST /auth/mfa/confirm`) and `api_v1_auth_mfa_verify_create` (`POST /auth/mfa/verify`) | `MFAVerify(challenge, code)`; challenge20โ€“200, code6โ€“64 | Enrollment confirmation and existing-factor verification are distinct transitions. Validate native completion refresh/device grant before publishing session. | +| `api_v1_auth_mfa_recovery_create` (`POST /auth/mfa/recovery`) | `MFAPassword(password)` | Regenerate codes in authenticated Security settings, not a guessed login-recovery route. Current challenge recovery entry uses verify with code according to the existing frontend flow. | +| `api_v1_auth_mfa_create` (`POST /auth/mfa`) / `api_v1_auth_mfa_disable_create` (`POST /auth/mfa/disable`) | `MFAPassword(password)` | Authenticated MFA setup/disable according to server authority. Disable requires a clear security confirmation; never claim it succeeded on a local toggle. | +| `api_v1_auth_social_nonce_create` (`POST /auth/social/nonce`) | `SocialNonce(provider, surface, delivery, client_platform)` with optional `challenge` | Use actual enum values from generated models; native social platform is `ios`/`android`, not device platform enum `apple`. | +| `api_v1_auth_social_google_create`, `api_v1_auth_social_apple_create`, `api_v1_auth_social_oidc_create` | `SocialLogin(id_token, nonce, surface, delivery, client_platform)` plus optional `apple_name`, `challenge`, unconstrained `attestation` | ID token must be server validated. Optional refresh/device grant and absent MFA union in `SocialLoginResponse` need safe handling, not assumptions. | + +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. + +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 + +Use actionable inline language. Never display raw JSON, stack traces, authorization values, server HTML, SDK exception class names, or bare numeric codes. A retry belongs beside the failed task. Preserve the current safe screen and useful inputs; do not reset the entire flow for every failure. + +| Condition | Message | Action/state | +|---|---|---| +| Login credentials rejected | โ€œThe email, username, or password didn't match. Try again.โ€ | Keep identifier; retain password only in current in-memory form so user can correct it. No account-existence distinction. | +| Missing required field | โ€œEnter your email or username.โ€ / โ€œEnter your password.โ€ | Associate with field; first invalid field gets focus on submit. | +| New password local checks | โ€œUse at least 8 characters.โ€ / โ€œPasswords don't match.โ€ | Independent of account/OTP validity; server can still reject per policy. | +| MFA invalid code | โ€œThat code didn't work. Check it and try again.โ€ | Keep active challenge if still valid; clear only submitted code as appropriate. Expired challenge uses its dedicated restart screen. | +| Reset confirmation rejected | โ€œInvalid or expired verification code.โ€ | Always identical for every server rejection; no conditional password-policy detail. | +| Network unavailable during mutation/auth | โ€œYou're offline. Connect to the internet and try again.โ€ | Stop busy state; explicit retry after reconnect. Do not queue signup, login, OTP, checkout, staff actions, deletion, or password changes silently. | +| Server temporarily unavailable | โ€œThis service isn't available right now. Try again shortly.โ€ | Keep task; respect Retry-After if supplied. A503 is not invalid credentials. | +| Rate limited | โ€œPlease wait before trying again.โ€; supporting โ€œTry again in {remaining}s.โ€ | Disable affected action until allowed; no per-second screen-reader announcements. | +| Permission denied403 | โ€œYou don't have access to this action.โ€ | Refetch authority if needed and return to safe context. Do not offer a retry loop that bypasses role checks. | +| Resource removed404 | โ€œThis item is no longer available.โ€ | Return to refreshed list; clear old QR/detail and dependent action. | +| Conflict409 | Task-specific โ€œThis changed while you were working. Refresh and try again.โ€ | Reload real resource before resubmitting; scanning uses โ€œScan againโ€. No fake rollback. | +| Native provider unavailable | โ€œThis sign-in option isn't available on this device.โ€ | Offer another configured method or help/change server. Provider cancel is neutral. | +| Secure storage failure | โ€œCouldn't save your sign-in securely. Try again.โ€ | Do not publish an in-memory authenticated session that will lose its refresh/grant; clean up partial state. | + +Retry-After parsing and timing belong to the existing networking core: support the actual header format, measure countdown against elapsed time, recompute after backgrounding, and never shorten a server wait. Use the greater of server wait and any documented local resend delay. The frontend's 60-second password-recovery resend delay is a UI rule, not a claimed server OTP expiry. Editing email, backing up a screen, rotating, retrying another button, or changing tabs must not bypass a server cooldown for the same server/operation. Do not refresh bearer tokens for an unauthenticated MFA challenge error. Busy/error/cooldown states are distinct. + +Offline after prior authentication can show only data that the app really caches, clearly labelled โ€œOffline ยท Last updated {time}โ€. A network error alone does not wipe valid tokens or assert revocation. Missing cache shows โ€œConnect to load your makerspaceโ€; never show a demo fixture as cached production data. Protected transactions require an online server. On resume/reconnect, refresh authorized membership and data before enabling transactions. Explicit offline logout still clears local protected state. + +## 9. First-use guidance across the app + +Keep the five main destinations Home, Inventory, Activity, Inbox, Profile. Staff tools are an explicit authorized destination/action, not an alternate sign-in identity. The first successful sign-in ends on the selected makerspace's Home. Show a single dismissible โ€œStart hereโ€ card with two relevant actions, โ€œBrowse inventoryโ€ and โ€œView bookingsโ€; if a module is unavailable, choose another enabled action rather than presenting a dead CTA. Do not gate app use on a tutorial checklist. + +Context tips are brief, dismissible, shown once per account + makerspace + feature version, and replayable from Help. No more than one educational callout per screen; operational warnings can coexist when necessary. Dismissing a tip does not dismiss a failure or consent requirement. See [Apple onboarding](https://developer.apple.com/design/human-interface-guidelines/onboarding) for task-time guidance. + +| Phase/flow | Context explanation and real action | Essential states and safety | +|---|---|---| +| A2 Home/read | โ€œYour makerspace, at a glance.โ€ Show real upcoming bookings/events and due loans; action opens the actual detail | Loading skeleton with stable layout; truly empty next-step card; failure with retry; partial section failure does not erase other successful sections. | +| A2 Inventory/detail | โ€œFind what you need, then request it from your makerspace.โ€ Search/filter and actual item detail | Distinguish โ€œNo items yetโ€ from โ€œNo items match these filtersโ€ โ†’ โ€œClear filtersโ€. Availability is server-authoritative. No raw IDs as primary titles. | +| A2 Loans/card/events/bookings/payments read | Use plain status words, dates with locale/timezone, real charges/amount/currency, card/ticket QR only when valid | Stale/unauthorized QR is removed; unavailable card explains โ€œAsk the front desk for help.โ€ Never show an expired QR as usable. | +| A3 Hardware request/cart | โ€œAdd the items you need. Staff will confirm your request.โ€ Review items/quantities, then โ€œSubmit requestโ€ | Validation/availability changes remain editable; show actual submitted result, not a fabricated handover. Empty cart โ†’ โ€œBrowse inventoryโ€. | +| A3 Renew loan | Explain the actual loan/date and action โ€œRequest renewalโ€ or โ€œRenew loanโ€ according to contract result | Pending/approved/rejected meanings distinct; do not silently replace the due date before server success. | +| A3 Booking | Choose resource โ†’ date/time โ†’ review โ†’ โ€œConfirm bookingโ€ | Clearly state makerspace timezone, duration, conflicts, cancellation policy when supplied. No arbitrary timezone conversion of selected slot. Failure preserves draft and refreshes availability. | +| A3 Event registration/ticket | Detail โ†’ actual registration choice โ†’ review cost/eligibility โ†’ register โ†’ real ticket | Sold out, waitlisted, payment pending, cancelled and already registered remain distinct. Cancel has an explicit confirmation. QR is not proof of payment or permission to check in without server verification. | +| A3 Profile/preferences | Group Profile, Notifications, Security, Membership, Devices; clear โ€œSave changesโ€ where required | Dirty edits have discard confirmation; failures keep edits; toggles don't claim success until saved. Updating phone/email follows real verification, not a local checkmark. | +| A4 Inbox/push | Inbox works without notification permission. First relevant prompt: โ€œGet updates from your makerspaceโ€; โ€œTurn on notifications for requests, bookings, and important updates.โ€ Actions โ€œEnable notificationsโ€, โ€œNot nowโ€ | Use real OS permission request only after Enable. Denied โ†’ quiet settings row, no repeated launch prompt. Push is a hint; authenticated refetch authorizes navigation. Unknown versions/malformed/external links fall back to Inbox. Missing/deleted target shows a safe unavailable state. | +| A5 Payment | Payments list/detail โ†’ review real amount/currency/charge; โ€œContinue to paymentโ€ opens configured SDK only with server-issued checkout input | SDK completion โ†’ โ€œChecking paymentโ€ฆโ€ and generated server refresh/poll. Only server-confirmed paid status shows a receipt. Cancel returns to review; pending/failed/expired may retry only through a fresh authorized checkout. Receipt/document uses a trusted actual document response and safe viewer/share action. No SDK launch in demo and no charge from preview data. | +| A6 Staff queues | Pending requests and booking approvals show requester, actual items/time, status, detail โ†’ explicit โ€œAcceptโ€/โ€œRejectโ€ or โ€œApproveโ€/โ€œDeclineโ€ with required reason | Refresh on conflict; do not remove a row or claim approval before success. Authority loss exits staff actions safely. Rejection has a deliberate confirmation and meaningful reason when required. | +| A6 Staff scan | โ€œScan to find a member or ticket.โ€ First entry explains camera purpose before OS request; manual lookup/entry remains available | Camera permission denied, no hardware, busy, unreadable code, revoked authority, offline, duplicate scan, invalid/expired QR all have clear states. Resolve person/ticket first, explicit โ€œConfirm check-inโ€ second. Never execute a server action merely on camera detection. | +| A6 Staff handover/return/direct handout | Container/member โ†’ items โ†’ required photo/notes โ†’ review โ†’ explicit confirm โ†’ receipt/result; direct handout follows the actual borrower/item selection contract | Gate each step by real data; show partial success precisely; unresolved charge/waiver is a real informational/blocking state per response. Do not invent successful issue/return or Undo without an actual reverse operation. Camera closes when leaving the scan screen. | +| A7 Devices/security | โ€œDevices signed in to your accountโ€; recognizable device/session description, current-device marker; per-device โ€œSign out deviceโ€ confirmation | Actual server list/revoke; current-device revoke clears local state. No false โ€œall devicesโ€ promise. MFA disable/regenerate are reauthenticated server actions. | +| A7 Membership | Differentiate โ€œCancel renewalโ€ (current term remains) and โ€œLeave makerspaceโ€ (ends membership as server indicates); explain consequences and confirm | Preserve history where the real app does. Leaving a makerspace is not deleting an account. Revoked membership removes that scope and reselects safely. | +| A7 Account deletion | โ€œDelete accountโ€; eligibility/blockers โ†’ real fix links โ†’ required reauthentication โ†’ explicit final confirmation | Production unavailable until deletion contract exists. Show โ€œAccount deletion isn't available in this app yet. Contact your makerspace for help.โ€ without claiming a deletion request was sent. Demo may exercise labelled simulated blockers/confirmation, but no production success or local logout masquerading as deletion. | +| A8 Help/release | Help can replay introduction, explain permissions, show app version/server and configured privacy/support links | Strings are localization-ready, no UI implementation/phase jargon. Reviewer/demo mode is obvious and separate from production. Store privacy descriptions must match actual SDK behavior. | + +Request camera only on Scan/Take photo; use native photo picker for an explicit attach action rather than broad library permission. Explain why a denied permission matters at that task and offer manual or settings fallback. Do not prevent ordinary inventory use because notifications or camera are denied. Recheck permission when each operation begins. [Android contextual permissions](https://developer.android.com/training/permissions/requesting). + +## 10. Adaptive layouts and accessibility + +Measure available window size, not device model or orientation alone. A tablet in split view behaves like the available compact width. Preserve entered data and current step across resize/rotation; don't repeat a provider launch, resend, SDK payment, or mutation. Use native navigation/back behavior. On tablets, popovers/sheets are anchored correctly and never display a phone form stretched across the screen. + +| Available width | Layout | +|---|---| +| Under600 logical units | One column, gutter16โ€“24 as space permits, form width fills available space up to460; symbol or compact stacked header; scrollable content. Intro illustration can collapse in short height. | +| 600โ€“959 | Centered single bounded task column, max460, gutter24โ€“32; optional quiet surrounding surface. App content may use a navigation rail/sidebar and real list/detail when both panes remain useful. | +| At least960 with adequate height and ordinary text size | Optional two-column auth: form max460, gap40โ€“48, brand panel roughly320โ€“400; balanced content area max1040. Put static stacked logo and one short value statement in the panel. All actual fields/actions stay in one reading column. | +| Any width with large accessibility text, large display zoom, or short keyboard-obscured height | Collapse brand panel and nonessential illustrations; one scrollable column; no clipped fixed-height cards. Preserve controls and text rather than scaling the page down. | + +No screen depends on landscape; no orientation lock to solve clipping. Auth may use a non-fullscreen tablet task card, but its entire form/error content must remain scrollable with hardware and software keyboards. Full-app master/detail selection survives navigation and resizing; a phone opens detail as a destination. Follow [Android window size classes](https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes) and [Apple adaptive layout](https://developer.apple.com/design/human-interface-guidelines/layout). + +Acceptance floor: VoiceOver/TalkBack labels and logical reading order; real headings; announced progress/errors without repeated countdown chatter; accessible selected-page/tab/checkbox states; 48-unit targets; keyboard traversal and visible focus; native text scaling; at least4.5:1 text contrast and meaningful non-text boundary/focus contrast. Screen-reader focus moves to the new heading on navigation, first invalid field on submit, and back to the invoking control after a dismissed sheet. Decorative illustration and duplicate logos are excluded. Do not mark an entire form as a single accessibility element. Dialogs trap focus appropriately; Back/escape dismisses safe dialogs and never confirms a destructive action. QR screens provide textual context and an available equivalent action, without announcing the secret QR payload. Sources: [Android accessible components](https://developer.android.com/develop/ui/compose/accessibility/api-defaults), [Android semantics](https://developer.android.com/develop/ui/compose/accessibility/semantics), [Apple accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility). + +## 11. Motion specification + +Use the existing brand timings generated from `tokens/mobile-motion.json`; do not hand-copy divergent constants into the two apps. Animate only a change that helps someone understand progress or hierarchy. Native interaction/state updates happen immediately; never delay a result or disable input until a decorative animation finishes. Respect the OS Reduce Motion/animator-duration setting from first render, including live changes. The shared reduced-motion contract sets custom opacity duration and blocking delay to zero and disables spatial transitions/decorative repeats. Avoid spring overshoot, parallax, shimmer loops, bouncing, confetti, auto-advancing carousels, and repeating card float effects. Native indeterminate progress remains available with an accessible label. + +| Transition | Standard behavior | Reduced motion | +|---|---|---| +| Launch | Static OS artwork; immediate state resolution | Same; no second splash | +| Intro page change | Content/illustration cards may crossfade and shift at most12 units over200ms; stationary independent logo/header and controls | Instant content replacement; no custom opacity animation or spatial movement | +| First task content appearance | One brief non-logo content fade, at most160โ€“200ms; no stagger delaying controls | Immediate display | +| Color feedback | 120ms | Immediate | +| Inline disclosure/expanded helper | 200ms | Immediate layout update; retain focus | +| Task sheet/modal | Enter240ms, exit160ms, cubic-bezier(.32,.72,0,1), native platform presentation where appropriate | Native reduced-motion presentation or immediate custom state change | +| Form error/success | Stable inline region, optional120ms opacity | Immediate; announce once, no shake | +| Main navigation | Native platform transition, no custom flourish on every tap | Honor native setting | + +Keep the stationary logo outside every animated subtree. Do not fade a parent panel that contains it. Native navigation transitions should not carry a custom floating logo between screens; use a stable shared header or render the correct static destination header. Motion decisions combine the brand specification with [Apple motion guidance](https://developer.apple.com/design/human-interface-guidelines/motion), [Apple Reduce Motion criteria](https://developer.apple.com/help/app-store-connect/manage-app-accessibility/reduced-motion-evaluation-criteria), and [Material motion principles](https://m3.material.io/styles/motion/overview/how-it-works). The numeric durations come from the Space Works brand, not a claim that the latest Material system mandates them. + +## 12. Debug/demo and preview requirements + +Demo mode is authorized to demonstrate complete flows while the owner supplies backend work later. Label entry and all authenticated demo screens with a persistent โ€œDemo modeโ€ indicator and offer โ€œExit demoโ€. Use an isolated demo gateway/store with deterministic, synthetic accounts/data; never insert demo fixtures into production repositories/caches/token storage. Show โ€œSimulatedโ€ on payment/deletion/staff success summaries. Demo mode is not a way to establish a production native grant. + +Provide a debug scenario picker for: first-run/returning; password success/rejection; MFA enrollment, invalid/expired verification, recovery and codes acknowledgement; signup/email verification; recovery request/confirm/success/unavailable; temporary password; zero/one/multiple memberships; revoked session; configured/unavailable/cancelled provider; offline/cooldown; minimum version; empty/error app sections; permission denied; invalid scan; payment pending/failure/server-confirmed simulation; deletion blockers/unavailable. Scenario names are developer controls, not end-user copy. Keep preview/demos unreachable in ordinary release unless an explicitly configured reviewer build calls for them. + +Demo must never request a live native provider token, register a real push device to a sample account, invoke payment SDK checkout, modify real staff inventory/check-in, or call deletion. Real camera permission/preview may be exercised only by a deliberately chosen hardware test, clearly separate from a simulated scan result. Never hardcode live secrets, user credentials, production provider tokens, or executable checkout inputs into demo data. Release builds must compile without debug fixtures reachable from normal navigation. + +## 13. Required implementation evidence + +Each agent should map its actual screens/actions to this specification and note any contract-dependent production branch in its delivery notes. A linked Markdown file alone does not finish the work. Follow the PRD's per-phase tests/commits; this UX checkpoint requires focused state tests, actual platform build checks, and rendered evidence. Do not generate hundreds of snapshots that only mirror implementation. + +### Functional acceptance journeys + +1. Fresh launch is usable without waiting for an animation. Skip and completion both persist; Help replay works without wiping account settings. No permission dialog appears during introduction. +2. Server bad URL/network/config failures remain editable and retryable; a failed candidate cannot replace the last validated origin or receive another origin's credentials. +3. Username and email/password login work with autofill/paste. Show/hide preserves selection/focus. Duplicate submit and stale callback cannot create two sessions. Secure-storage failure does not expose a signed-in shell. +4. Config controls actual methods/signup; no configured methods has a useful recovery state. Native provider cancel is neutral. Google manual chooser parses real credential type and clears selection state on sign-out; missing native grant does not enter Home. +5. Signup sends only model fields, receives acknowledgement, clears password, and returns to normal native login. Email verification/restricted session cannot unlock main tabs before refreshed authority. +6. Forgot/reset uses the real generated operations; local8-character/match checks, full OTP paste, resend60s plus longer server wait, same reset-rejection text for every server rejection, success back to sign-in, known unavailable branch. +7. MFA enrollment exposes a real/manual setup key, verifies once, shows real returned recovery codes, requires acknowledgement. Recovery accepts non-numeric codes. Expiry/back/account change clears challenge secrets and rejects stale results. +8. Temporary-password gate blocks tabs until generated change-password plus account refresh clears it. Password rejection remains correctable; sign-out remains possible. +9. Zero/one/multiple membership cases and revoked membership do not leak the previous space/account cache. Switching after a pending link reauthorizes the target. +10. Auth network failure does not become token revocation. Offline cached views are labelled; offline explicit logout wipes data. Server429/Retry-After survives resize/background and cannot be bypassed by editing or navigation. +11. First-use tips dismiss/replay, remain scoped correctly, and never block ordinary actions. Empty/filter-empty/error/partial-load states have meaningful next actions. +12. Camera and notifications are requested only after a task action; denial has manual/Inbox fallback. Camera releases on exit/background as appropriate. Push malformed/external/stale targets cannot route around auth/membership. +13. Payment SDK completion never displays paid before server confirmation. Demo never launches checkout. Staff scan detection never performs check-in without confirmation. Production deletion remains unavailable until its actual contract exists. +14. Explicit logout/account/server switch clears pending SDK callbacks, cache, cart, QR and secure state; no old account content flashes on the next sign-in. +15. Release configuration has no reachable demo gateway/secret fixture path. All displayed production successes are backed by generated server state. + +### Rendered and accessibility evidence + +Capture a small purposeful matrix on each platform: smallest supported phone portrait with keyboard; representative modern phone; large phone/landscape; tablet portrait and landscape; tablet split/narrow window. Include light and dark, largest supported accessibility text, and reduced motion. At minimum render introduction, sign-in with real provider styling, field error, MFA/recovery, password reset, membership picker, Home, and one contextual permission/denial state across that matrix. Verify no clipping, stretched logo, logo motion, hidden CTA, duplicate focus stop, or inaccessible dark link. Record tested device/window dimensions and settings rather than claiming universal coverage from one screenshot. + +Manually exercise TalkBack/VoiceOver through sign-in, OTP paste, show/hide, errors, modal cancel, and makerspace selection; hardware keyboard traversal on a tablet; and real native autofill/provider UI on an appropriately configured device. CI should compile debug/release SDK paths and run meaningful coordinator/state tests. macOS/Xcode is required for iOS build and simulator evidence; Windows source lint is not a substitute. Native payment, push, camera, and attestation need configured physical-device smoke checks before store release. Document unavailable environment evidence honestly rather than marking it passed. diff --git a/project.yml b/project.yml index 7060f16..648d6b0 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,12 +10,25 @@ configFiles: 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 MARKETING_VERSION: 0.1.0 GENERATE_INFOPLIST_FILE: YES 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 SpaceWorksAPI: path: Packages/SpaceWorksAPI targets: @@ -26,19 +40,30 @@ targets: excludes: [Generated/Info-Debug.plist, Generated/Info-Release.plist] dependencies: - package: SpaceWorksAPI + - package: GoogleSignIn + product: GoogleSignIn + - package: Stripe + product: StripePaymentSheet + - package: Stripe + product: StripePayments + - package: Razorpay + product: RazorpayCheckout settings: 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) 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: - name: Generate branding and semantic theme script: python3 "$SRCROOT/scripts/generate.py" @@ -71,4 +96,7 @@ schemes: SpaceWorksUITests: [test] test: gatherCoverageData: true + environmentVariables: + SPACEWORKS_TEST_PROFILE: $(SPACEWORKS_TEST_PROFILE) + SPACEWORKS_SETUP_DIAGNOSTIC: $(SPACEWORKS_SETUP_DIAGNOSTIC) targets: [SpaceWorksTests, SpaceWorksUITests] diff --git a/scripts/audit_bundle.py b/scripts/audit_bundle.py new file mode 100644 index 0000000..e665301 --- /dev/null +++ b/scripts/audit_bundle.py @@ -0,0 +1,68 @@ +"""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') + 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') + 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({'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') + 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'), '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 + + +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/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/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/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/device-matrix.py b/scripts/device-matrix.py new file mode 100644 index 0000000..779df21 --- /dev/null +++ b/scripts/device-matrix.py @@ -0,0 +1,65 @@ +"""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, '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: + 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/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 68e3ad4..20ca673 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -5,10 +5,12 @@ """ import argparse import hashlib +import importlib.util import json import pathlib import plistlib import re +import shutil import subprocess import xml.etree.ElementTree as ET @@ -29,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}") @@ -43,18 +76,50 @@ 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" + "\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", [])) + "\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"] + 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: + 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)) - tokens = json.loads(inside(brand["theme_tokens"]).read_text()) + entitlements = { + "com.apple.developer.devicecheck.appattest-environment": "development" if mode == "Debug" else "production", + "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"] + (ROOT / "SpaceWorks/Generated/Simulator.entitlements").write_bytes(plistlib.dumps({ + "application-identifier": simulator_identity, + "keychain-access-groups": [simulator_identity], "get-task-allow": True})) + # 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" @@ -75,6 +140,31 @@ 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) + fixture_spec = importlib.util.spec_from_file_location("spaceworks_fixtures", ROOT / "scripts/generate_member_fixtures.py") + 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) + 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) + for asset in ["Wordmark", "BrandStacked", "BrandSymbol"]: + target = assets / f"{asset}.imageset" + target.mkdir(parents=True, exist_ok=True) + 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}})) icon = assets / "AppIcon.appiconset" icon.mkdir(parents=True, exist_ok=True) if raster: @@ -94,8 +184,17 @@ 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"]} + 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/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/generate_member_fixtures.py b/scripts/generate_member_fixtures.py new file mode 100644 index 0000000..2f74063 --- /dev/null +++ b/scripts/generate_member_fixtures.py @@ -0,0 +1,14 @@ +"""Build DEBUG-only fixture constants; raw fixture files never enter the application resource bundle.""" +import pathlib + + +def generate_member_fixtures(root): + root = pathlib.Path(root) + lines = ["// Generated synthetic contract fixtures. No Release implementation.", "#if DEBUG", "enum DebugMemberFixtures {"] + for source in sorted((root / "Fixtures/Member").glob("*.json")): + text = source.read_text(encoding="utf-8").strip() + if '"""#' in text or '\\#(' in text or not source.stem.isidentifier(): + raise ValueError("Unsafe raw Swift literal") + lines.append(f' static let {source.stem} = #"""\n{text}\n"""#') + lines.extend(["}", "#endif", ""]) + (root / "SpaceWorks/Generated/DebugMemberFixtures.swift").write_text("\n".join(lines), encoding="utf-8") diff --git a/scripts/generate_motion.py b/scripts/generate_motion.py new file mode 100644 index 0000000..46d6334 --- /dev/null +++ b/scripts/generate_motion.py @@ -0,0 +1,28 @@ +"""Generate native motion only from the shared mobile repository 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 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 / "tokens/mobile-motion.json").read_text()) + (root / "SpaceWorks/Generated/MobileMotion.swift").write_text(render_motion(document), encoding="utf-8") 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/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_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")) diff --git a/scripts/tests/test_audit_bundle.py b/scripts/tests/test_audit_bundle.py new file mode 100644 index 0000000..5855ab4 --- /dev/null +++ b/scripts/tests/test_audit_bundle.py @@ -0,0 +1,73 @@ +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', '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']}]})) + 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') + + 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['scene_manifest'], {'UIApplicationSupportsMultipleScenes': False}) + 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() + 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_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_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()) 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_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') 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() diff --git a/scripts/tests/test_generation.py b/scripts/tests/test_generation.py index 3056333..4525af3 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] @@ -18,10 +21,17 @@ 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") - self.assertEqual(len(json.loads(source)["paths"]), 635) + 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"]} + 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"]), 760) def test_brand_config_drives_project_display_name_bundle_and_icon(self): brand = json.loads((ROOT / "branding/brand.json").read_text()) @@ -33,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() @@ -41,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"] @@ -62,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])]) @@ -73,6 +149,23 @@ 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()) + 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__": 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() diff --git a/scripts/tests/test_mobile_capabilities.py b/scripts/tests/test_mobile_capabilities.py new file mode 100644 index 0000000..4687eb3 --- /dev/null +++ b/scripts/tests/test_mobile_capabilities.py @@ -0,0 +1,75 @@ +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).resolve() + 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) + 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() diff --git a/scripts/tests/test_motion.py b/scripts/tests/test_motion.py new file mode 100644 index 0000000..16828f1 --- /dev/null +++ b/scripts/tests/test_motion.py @@ -0,0 +1,41 @@ +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_local_contract(self): + document = json.loads((ROOT / "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 / "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_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" + 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()) 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() diff --git a/tokens/mobile-motion.json b/tokens/mobile-motion.json new file mode 100644 index 0000000..817b646 --- /dev/null +++ b/tokens/mobile-motion.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "provenance": "design/docs/ui-brand-spec.md, section E motion table; owner mobile UX extension 2026-10-16", + "duration_ms": { + "color": 120, + "expand": 200, + "overlay_enter": 240, + "overlay_exit": 160 + }, + "easing": { + "overlay_enter_cubic_bezier": [0.32, 0.72, 0.0, 1.0] + }, + "onboarding": { + "blocking_delay_ms": 0, + "logo_motion": "none", + "optional": true, + "replay_from_help": true + }, + "reduce_motion": { + "spatial_transitions": false, + "decorative_repeats": false, + "blocking_delay_ms": 0, + "opacity_duration_ms": 0 + } +}