diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..3023984b5 Binary files /dev/null and b/.DS_Store differ diff --git a/.clasp.json b/.clasp.json deleted file mode 100644 index b61136bf4..000000000 --- a/.clasp.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "scriptId": "1ohzyuJgn89_Ovj1-DT5JrN-T8uuRAyxdCl3OECDuXtx4BrX4CsyHi61C", - "rootDir": "./dist", - "parentId": ["12owkz3UxzEjyj8vNnsQXq_o0exBquvg4A6D_MMoE3g8"] -} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d27e012d0..cc90f25e4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ ## Checklist diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py new file mode 100644 index 000000000..894922ed1 --- /dev/null +++ b/.github/scripts/submit_review.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Submit the Claude review payload as one PR review, resilient to inline +comments GitHub cannot anchor to the diff. + +The model writes ``/tmp/review.json`` with inline comments keyed to new-file +line numbers. GitHub's create-review API rejects the WHOLE review (HTTP 422, +"Line could not be resolved") if any single comment's line falls outside the +PR's diff hunks. To stay green without dropping findings, this script: + + 1. Computes the set of commentable RIGHT-side lines from ``gh pr diff``. + 2. Keeps inline comments whose (path, line) is anchorable; folds the rest + into the review body so the finding still shows up. + 3. POSTs once. On any residual failure, retries body-only, then gives up + gracefully -- a review-infra hiccup must not block the PR. + +Trust boundary: this is the trusted step. The endpoint is hardcoded here and +the model never holds ``gh api``; a malicious diff can only influence comment +*text* (which already lands in the review body either way), never which API +call runs. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess + +REVIEW_PATH = "/tmp/review.json" + + +def parse_commentable_lines(diff: str) -> dict[str, set[int]]: + """Map new-file path -> set of RIGHT-side line numbers inside diff hunks. + + GitHub allows a RIGHT-side comment on added (``+``) and context (`` ``) + lines within a hunk; removed (``-``) lines have no new-file line. Pure + function over unified-diff text so it can be unit-tested without network. + """ + valid: dict[str, set[int]] = {} + path: str | None = None + newln = 0 + in_hunk = False + for line in diff.splitlines(): + # A new file section resets state so inter-file metadata + # (``diff --git``, ``index``, ``--- ``/``+++ `` headers) is never + # mistaken for hunk content. + if line.startswith("diff --git"): + path, in_hunk = None, False + continue + if not in_hunk: + if line.startswith("+++ "): + p = line[4:].strip() + if p.startswith("b/"): + p = p[2:] + path = None if p == "/dev/null" else p + if path is not None: + valid.setdefault(path, set()) + elif line.startswith("@@"): + m = re.search(r"\+(\d+)", line) + newln = int(m.group(1)) if m else 0 + in_hunk = True + continue + # Inside a hunk. + if line.startswith("@@"): # next hunk of the same file + m = re.search(r"\+(\d+)", line) + newln = int(m.group(1)) if m else 0 + continue + if path is None: + continue + if line.startswith("+"): # added line (commentable on RIGHT) + valid[path].add(newln) + newln += 1 + elif line.startswith(" "): # context line (commentable on RIGHT) + valid[path].add(newln) + newln += 1 + # ``-`` (removed, no RIGHT line) and ``\`` ("No newline") consume nothing. + return valid + + +def post(repo: str, pr: str, payload: dict) -> tuple[int, str]: + # Review posting is advisory and must never crash the job — surface a + # missing/broken gh binary as a failed attempt, not an unhandled exception. + try: + proc = subprocess.run( + ["gh", "api", "--method", "POST", + f"/repos/{repo}/pulls/{pr}/reviews", "--input", "-"], + input=json.dumps(payload), capture_output=True, text=True, + ) + except OSError as exc: + return 1, f"could not invoke gh: {exc}" + return proc.returncode, (proc.stdout + proc.stderr).strip() + + +def partition_comments( + comments: list[dict], valid: dict[str, set[int]] +) -> tuple[list[dict], list[dict]]: + """Split comments into (anchorable-inline, must-fold-into-body). + + A comment is anchorable when it targets the RIGHT side and both its + ``line`` and (for a multi-line span) ``start_line`` land on a commentable + line of the file in the diff. + """ + kept, folded = [], [] + for c in comments: + p, ln, sl = c.get("path"), c.get("line"), c.get("start_line") + anchorable = ( + c.get("side", "RIGHT") == "RIGHT" + and p in valid and ln in valid[p] + and (sl is None or sl in valid[p]) + ) + (kept if anchorable else folded).append(c) + return kept, folded + + +def _loc(c: dict) -> str: + return f"{c.get('path')}:{c.get('line')}" + + +def main() -> None: + pr = os.environ["PR_NUMBER"] + repo = os.environ["REPO"] + # The model occasionally writes a /tmp/review.json that isn't valid JSON + # (e.g. an unescaped quote or newline inside a comment body), which made + # ``json.load`` raise and fail the whole job. Review posting is advisory + # and must not block the PR -- mirror the graceful give-up the submit + # fallbacks already use: log loudly and return. Re-running the review + # regenerates the file. + try: + with open(REVIEW_PATH) as f: + review = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print( + f"warning: could not read/parse {REVIEW_PATH} ({exc}); " + "skipping review submit, not blocking the PR." + ) + return + if not isinstance(review, dict): + print( + f"warning: {REVIEW_PATH} is not a JSON object " + f"(got {type(review).__name__}); skipping review submit, " + "not blocking the PR." + ) + return + # review.json is untrusted model output: the review verdict is pinned to + # COMMENT (never APPROVE/REQUEST_CHANGES) and commit_id comes from the + # workflow env, so an injected payload can only influence comment text. + raw_comments = review.get("comments") + comments = ( + [c for c in raw_comments if isinstance(c, dict)] + if isinstance(raw_comments, list) + else [] + ) + body = review.get("body") + body = body if isinstance(body, str) else "" + base = {"event": "COMMENT"} + head_sha = os.environ.get("HEAD_SHA") + if head_sha: + base["commit_id"] = head_sha + + valid: dict[str, set[int]] = {} + try: + diff = subprocess.run( + ["gh", "pr", "diff", pr], capture_output=True, text=True, check=True + ).stdout + valid = parse_commentable_lines(diff) + except Exception as exc: # diff fetch/parse failed -> fall back to body-only + print(f"warning: could not compute diff lines ({exc}); folding all inline comments") + + kept, folded = partition_comments(comments, valid) + + # GitHub's create-review API requires start_side when start_line is set; + # a comment that omits it would 422 the whole review. + for c in kept: + if c.get("start_line") is not None: + c.setdefault("start_side", "RIGHT") + + def fold_into(text: str, items: list[dict], heading: str) -> str: + if not items: + return text + out = text + f"\n\n---\n**{heading}:**\n" + for c in items: + out += f"\n- `{_loc(c)}` -- {c.get('body', '').strip()}" + return out + + # Attempt 1: anchorable comments inline, the rest folded into the body. + body1 = fold_into(body, folded, "Findings that could not be anchored to the diff") + rc, out = post(repo, pr, {**base, "body": body1, "comments": kept}) + if rc == 0: + print(f"Posted review: {len(kept)} inline, {len(folded)} folded into body.") + return + print(f"Inline submit failed (rc={rc}); falling back to body-only.\n{out}") + + # Attempt 2: everything in the body, no inline anchors at all. + body2 = fold_into(body1, kept, "Inline findings (anchoring unavailable)") + rc, out = post(repo, pr, {**base, "body": body2 or "Review produced no anchorable findings.", + "comments": []}) + if rc == 0: + print("Posted body-only review.") + return + + # A body-only COMMENT review needs no line resolution, so this is an + # auth/rate-limit/transport problem, not the 422 we set out to fix. + # Log loudly but do not fail the job: review posting is advisory and must + # not block the PR. + print(f"warning: review submit failed entirely (rc={rc}); not blocking the PR.\n{out}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..85514d1c8 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,200 @@ +name: Claude PR Review + +on: + pull_request: + branches: [main] + # ready_for_review is required so a draft promoted to ready gets reviewed — + # without it the draft guard below would silently skip such PRs forever. + # reopened is required for the same reason: a closed-then-reopened PR with + # no new commit would otherwise never be reviewed. + types: [opened, synchronize, ready_for_review, reopened] + +# One review per PR; a new push cancels the in-flight review instead of +# stacking duplicate threads (and duplicate API spend). +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + review: + # Skip drafts, and skip Dependabot PRs — Dependabot-triggered runs don't get + # the repo's Actions secrets, so the auth step can't load the OAuth token + # and the check fails. Dep bumps don't need an AI code review anyway. + # Also skip fork PRs: they don't receive secrets either (the review can't + # auth), and gating on same-repo keeps the write-scoped GITHUB_TOKEN away + # from untrusted heads entirely. + if: >- + github.actor != 'dependabot[bot]' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + # A hung API call would otherwise hold the runner for the 6h default. + # Sized to fit the --max-turns budget below (~25s/turn observed ≈ 42 min + # at 100 turns) with margin, so the turn cap — not this wall clock — + # stays the limiting factor. + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Claude holds read-only file tools (Read/Grep/Glob below), and + # actions/checkout by default persists the write-scoped GITHUB_TOKEN + # into .git/config — an on-disk secret a prompt injection could read + # and exfiltrate through the review text. Nothing downstream needs + # git-over-HTTP auth: `gh pr diff` authenticates via the env token, + # and the submit step only runs local git (rev-parse/show) against + # refs this full fetch already brought down. + persist-credentials: false + + - name: Claude review + # Pinned to the commit the v1 tag pointed at (mutable tags are a + # supply-chain risk for a step that runs with repo credentials). + uses: anthropics/claude-code-action@36a69b6a90b850823f86de06fdfd56264772ad98 # v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Claude subscription auth (no Bedrock / no API key). Generate with + # `claude setup-token` and store as the CLAUDE_CODE_OAUTH_TOKEN secret. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Claude only READS (gh pr diff, plus local Read/Grep/Glob over the + # checkout) and WRITES the review payload to a file. The local file + # tools exist so the review can trace cross-file impact — a diff-only + # view kept missing bugs whose trigger lives outside the changed + # hunks (e.g. a new gate blocking on state that another file never + # clears). They add no new injection lever: the checkout content is + # the same attacker-controlled input as the diff, the runner is + # ephemeral, and with persist-credentials: false there is no on-disk + # secret for a read tool to reach. + # It is deliberately NOT given gh api: the PR diff is attacker-controlled, + # so a prompt injection must not be able to choose which API endpoint runs. + # Accepted residual risk: the allowlist relies on Claude Code's permission + # engine, which evaluates compound commands (;, &&, |) per segment and + # rejects command substitution in non-interactive mode — not on naive + # string-prefix matching. If that engine were bypassed, the blast radius + # is contained by the submit step (base-ref script, env -i, absolute + # paths); token exfiltration from the agent's own step is inherent to + # running a hosted agent and is not mitigable at this layer. + # The fixed "Submit review" step below makes the one hardcoded POST call. + # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) + # silently blocked the /tmp/review.json write and the review never posted. + # The writes with an execution path — submit_review.py itself, and the + # $GITHUB_ENV/$GITHUB_PATH file commands — are neutralized by the submit + # step executing the BASE-ref copy via absolute paths under a clean + # `env -i` environment; the runner is ephemeral, and the real injection + # lever (the API call) is gone. + # max-turns must cover: read the diff, reason over every hunk, chase + # callers/definitions of changed code through the checkout, then + # write /tmp/review.json. Large PRs keep exhausting smaller budgets + # before the write, ending in error_max_turns with no review posted. + # timeout-minutes above is sized to fit 100 turns (~25s/turn ≈ 42 min) + # so the turn cap stays the limiting factor. + claude_args: >- + --model claude-sonnet-5 + --max-turns 100 + --allowedTools "Bash(gh pr diff:*),Read,Grep,Glob,Write" + prompt: | + You are reviewing pull request #${{ github.event.pull_request.number }} + in the repository ${{ github.repository }}. The head commit SHA is + ${{ github.event.pull_request.head.sha }}. The repository is checked + out in your working directory at the PR's merge result, so you can + Read/Grep/Glob any file — not just the diff. + + Report ONLY High and Medium severity findings: + - High: correctness bugs, security issues (auth, injection, secret + leakage, unsafe file handling), data loss, multi-tenant / app-scope + boundary breaks. + - Medium: likely-incorrect behavior, missing error handling, race + conditions, configuration that fails silently. + Do NOT report Low severity, nitpicks, style, formatting, naming, or + subjective preferences. If a finding is not clearly High or Medium, omit it. + + Post everything as ONE single pull request review (GitHub Copilot style), + NOT as separate standalone comments. Steps: + 1. Read the diff: gh pr diff ${{ github.event.pull_request.number }} + 2. Trace the diff through the rest of the codebase — bugs whose + trigger lives outside the changed hunks are in scope: + - For each function/component the diff changes or newly calls, + Read the full enclosing file and Grep for its callers and + consumers; check the change holds on every path that reaches it. + - For state, flags, or context values the new code reads + (loading flags, permissions, settings), find where they are + set and cleared, and check every branch — including the + unauthenticated / empty / error cases. + - When new enforcement or validation is added (auth checks, + rate limits, feature gates), Grep for sibling code paths that + mutate the same data and would bypass it. + - Before reporting a finding, verify the trigger path in the + surrounding code instead of assuming it from the diff alone; + drop findings the surrounding code refutes. + 3. For each High/Medium finding, note the file path and the exact line + number in the NEW version of the file (the right side of the diff). + 4. Use the Write tool to create /tmp/review.json with this shape: + { + "event": "COMMENT", + "commit_id": "${{ github.event.pull_request.head.sha }}", + "body": "<1-2 sentence summary, e.g. '2 High, 1 Medium.'>", + "comments": [ + {"path": "", "line": , "side": "RIGHT", + "body": "**[High]** "} + ] + } + Prefix every comment body with **[High]** or **[Medium]**. For a + multi-line span, also set "start_line" and "start_side": "RIGHT". + 5. Do NOT submit the review yourself and do NOT call gh api — you do not + have that permission. Your only job is to WRITE /tmp/review.json. A + separate trusted workflow step submits it. + Always write /tmp/review.json. If there are no High or Medium findings, + write it with an empty "comments" array and body "No High or Medium + severity issues found." + + # Trusted submit: the endpoint is hardcoded in the script, so even if a + # malicious PR injects instructions into the diff, it can only influence + # comment text — never which API call runs. Claude never holds an + # API-write tool. The script also drops inline comments GitHub can't + # anchor to the diff (which otherwise 422 the whole review) by folding + # them into the review body, so one bad line number no longer fails the + # job or loses the finding. + - name: Submit batched review + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Execute the submit script from the trusted BASE ref, not the PR's + # copy. The PR checkout is untrusted twice over: the Claude step + # holds an unscoped Write tool (runtime tampering), and the PR + # itself may commit an edited submit_review.py (committed + # tampering). Neither version may run with the write-scoped token. + # + # All binaries are invoked by absolute path and the script runs + # under `env -i` with an explicit allowlist: the Claude step's + # unscoped Write could poison $GITHUB_ENV/$GITHUB_PATH, and a + # shadowed `gh` on PATH or an injected GH_HOST would otherwise + # redirect the hardcoded API call (token included) elsewhere. + # Fail closed: if the script cannot be read from the base ref for + # ANY reason (unresolvable ref, deleted script, transient git + # error), skip the submit rather than ever executing the PR's copy. + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + if ! /usr/bin/git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py 2>/dev/null; then + echo "error: could not read submit_review.py from $BASE_REF; refusing to run the PR's copy" + exit 1 + fi + SCRIPT=/tmp/submit_review.py + if [ ! -f /tmp/review.json ]; then + echo "No /tmp/review.json produced; nothing to submit." + exit 0 + fi + /usr/bin/env -i \ + PATH=/usr/bin:/bin \ + HOME="$HOME" \ + GH_TOKEN="$GH_TOKEN" \ + PR_NUMBER="$PR_NUMBER" \ + REPO="$REPO" \ + HEAD_SHA="$HEAD_SHA" \ + /usr/bin/python3 "$SCRIPT" diff --git a/.github/workflows/deploy_development_app_engine.yml b/.github/workflows/deploy_development_app_engine.yml index 299861149..9e0f04a8c 100644 --- a/.github/workflows/deploy_development_app_engine.yml +++ b/.github/workflows/deploy_development_app_engine.yml @@ -82,55 +82,17 @@ jobs: cd booking-app npm run build - # Sync Firestore composite indexes for this environment's database. - # Uses gcloud directly (not `firebase deploy`) because firebase-tools - # 15.x throws TypeError on the multi-database `firestore` array in - # firebase.json, masking real failures behind a generic wrapper. + # Sync Firestore composite indexes for this environment's database from + # firestore.indexes.json (single source of truth — add indexes there, not + # here). Uses gcloud directly (not `firebase deploy`) because + # firebase-tools 15.x throws TypeError on the multi-database `firestore` + # array in firebase.json, masking real failures behind a generic wrapper. # gcloud is already authenticated via setup-gcloud above. - name: Deploy Firestore indexes timeout-minutes: 20 run: | - DB='(default)' - PROJECT="${{ secrets.GCP_PROJECT_ID }}" - - create_index() { - local CG="$1" - shift - echo "Ensuring composite index on $CG in $DB..." - set +e - OUTPUT=$(gcloud firestore indexes composite create \ - --collection-group="$CG" \ - --database="$DB" \ - --project="$PROJECT" \ - "$@" \ - --quiet 2>&1) - EXIT=$? - set -e - if [ $EXIT -ne 0 ]; then - if echo "$OUTPUT" | grep -qi "ALREADY_EXISTS"; then - echo "Index already exists for $CG, skipping" - return 0 - fi - echo "Failed to create index for $CG:" - echo "$OUTPUT" - return 1 - fi - echo "$OUTPUT" - } - - BOOKINGS_FIELDS=( - --field-config=field-path=email,order=ascending - --field-config=field-path=startDate,order=descending - ) - BOOKING_LOGS_FIELDS=( - --field-config=field-path=calendarEventId,order=ascending - --field-config=field-path=status,order=ascending - ) - - create_index mc-bookings "${BOOKINGS_FIELDS[@]}" - create_index itp-bookings "${BOOKINGS_FIELDS[@]}" - create_index mc-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" - create_index itp-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" + bash booking-app/scripts/deploy-firestore-indexes.sh \ + '(default)' "${{ secrets.GCP_PROJECT_ID }}" - name: Deploy to App Engine run: | diff --git a/.github/workflows/deploy_production_app_engine.yml b/.github/workflows/deploy_production_app_engine.yml index f3c88fccc..8e9cbffcc 100644 --- a/.github/workflows/deploy_production_app_engine.yml +++ b/.github/workflows/deploy_production_app_engine.yml @@ -82,55 +82,17 @@ jobs: cd booking-app npm run build - # Sync Firestore composite indexes for this environment's database. - # Uses gcloud directly (not `firebase deploy`) because firebase-tools - # 15.x throws TypeError on the multi-database `firestore` array in - # firebase.json, masking real failures behind a generic wrapper. + # Sync Firestore composite indexes for this environment's database from + # firestore.indexes.json (single source of truth — add indexes there, not + # here). Uses gcloud directly (not `firebase deploy`) because + # firebase-tools 15.x throws TypeError on the multi-database `firestore` + # array in firebase.json, masking real failures behind a generic wrapper. # gcloud is already authenticated via setup-gcloud above. - name: Deploy Firestore indexes timeout-minutes: 20 run: | - DB='booking-app-prod' - PROJECT="${{ secrets.GCP_PROJECT_ID }}" - - create_index() { - local CG="$1" - shift - echo "Ensuring composite index on $CG in $DB..." - set +e - OUTPUT=$(gcloud firestore indexes composite create \ - --collection-group="$CG" \ - --database="$DB" \ - --project="$PROJECT" \ - "$@" \ - --quiet 2>&1) - EXIT=$? - set -e - if [ $EXIT -ne 0 ]; then - if echo "$OUTPUT" | grep -qi "ALREADY_EXISTS"; then - echo "Index already exists for $CG, skipping" - return 0 - fi - echo "Failed to create index for $CG:" - echo "$OUTPUT" - return 1 - fi - echo "$OUTPUT" - } - - BOOKINGS_FIELDS=( - --field-config=field-path=email,order=ascending - --field-config=field-path=startDate,order=descending - ) - BOOKING_LOGS_FIELDS=( - --field-config=field-path=calendarEventId,order=ascending - --field-config=field-path=status,order=ascending - ) - - create_index mc-bookings "${BOOKINGS_FIELDS[@]}" - create_index itp-bookings "${BOOKINGS_FIELDS[@]}" - create_index mc-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" - create_index itp-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" + bash booking-app/scripts/deploy-firestore-indexes.sh \ + 'booking-app-prod' "${{ secrets.GCP_PROJECT_ID }}" - name: Deploy to App Engine run: | diff --git a/.github/workflows/deploy_staging_app_engine.yml b/.github/workflows/deploy_staging_app_engine.yml index a3372077e..3af02c5df 100644 --- a/.github/workflows/deploy_staging_app_engine.yml +++ b/.github/workflows/deploy_staging_app_engine.yml @@ -82,55 +82,17 @@ jobs: cd booking-app npm run build - # Sync Firestore composite indexes for this environment's database. - # Uses gcloud directly (not `firebase deploy`) because firebase-tools - # 15.x throws TypeError on the multi-database `firestore` array in - # firebase.json, masking real failures behind a generic wrapper. + # Sync Firestore composite indexes for this environment's database from + # firestore.indexes.json (single source of truth — add indexes there, not + # here). Uses gcloud directly (not `firebase deploy`) because + # firebase-tools 15.x throws TypeError on the multi-database `firestore` + # array in firebase.json, masking real failures behind a generic wrapper. # gcloud is already authenticated via setup-gcloud above. - name: Deploy Firestore indexes timeout-minutes: 20 run: | - DB='booking-app-staging' - PROJECT="${{ secrets.GCP_PROJECT_ID }}" - - create_index() { - local CG="$1" - shift - echo "Ensuring composite index on $CG in $DB..." - set +e - OUTPUT=$(gcloud firestore indexes composite create \ - --collection-group="$CG" \ - --database="$DB" \ - --project="$PROJECT" \ - "$@" \ - --quiet 2>&1) - EXIT=$? - set -e - if [ $EXIT -ne 0 ]; then - if echo "$OUTPUT" | grep -qi "ALREADY_EXISTS"; then - echo "Index already exists for $CG, skipping" - return 0 - fi - echo "Failed to create index for $CG:" - echo "$OUTPUT" - return 1 - fi - echo "$OUTPUT" - } - - BOOKINGS_FIELDS=( - --field-config=field-path=email,order=ascending - --field-config=field-path=startDate,order=descending - ) - BOOKING_LOGS_FIELDS=( - --field-config=field-path=calendarEventId,order=ascending - --field-config=field-path=status,order=ascending - ) - - create_index mc-bookings "${BOOKINGS_FIELDS[@]}" - create_index itp-bookings "${BOOKINGS_FIELDS[@]}" - create_index mc-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" - create_index itp-bookingLogs "${BOOKING_LOGS_FIELDS[@]}" + bash booking-app/scripts/deploy-firestore-indexes.sh \ + 'booking-app-staging' "${{ secrets.GCP_PROJECT_ID }}" - name: Deploy to App Engine run: | diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 975d1f4e1..9bb683985 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -24,9 +24,7 @@ jobs: - name: Install dependencies run: | cd booking-app - # Clean install to avoid optional dependencies issues - rm -rf node_modules package-lock.json - npm install --force + npm ci - name: Run unit tests env: diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9426e4e39..000000000 --- a/.gitignore +++ /dev/null @@ -1,67 +0,0 @@ -# Created by https://www.toptal.com/developers/gitignore/api/macos,windows -# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -.firebase -# End of https://www.toptal.com/developers/gitignore/api/macos,windows - - -app.yaml diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 034e84803..000000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/booking-app/.firebaserc b/booking-app/.firebaserc deleted file mode 100644 index fda5c666e..000000000 --- a/booking-app/.firebaserc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "projects": { - "default": "flowing-mantis-389917", - "production": "flowing-mantis-389917" - }, - "targets": {}, - "etags": {}, - "dataconnectEmulatorConfig": {} -} \ No newline at end of file diff --git a/booking-app/app/api/approve/route.ts b/booking-app/app/api/approve/route.ts index 28795c2bf..3982f326d 100644 --- a/booking-app/app/api/approve/route.ts +++ b/booking-app/app/api/approve/route.ts @@ -1,13 +1,22 @@ -import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; +import { + DEFAULT_TENANT, + isValidTenant, +} from "@/components/src/constants/tenants"; import { TableNames } from "@/components/src/policy"; import { isServicesRequestState, notifyServiceApproversForRequestedServices, } from "@/components/src/server/serviceApproverNotifications"; import { serverApproveBooking } from "@/components/src/server/admin"; -import { BookingStatusLabel } from "@/components/src/types"; +import { BookingStatusLabel, PagePermission } from "@/components/src/types"; import { getMediaCommonsServices, isMediaCommons } from "@/components/src/utils/tenantUtils"; -import { serverGetDataByCalendarEventId } from "@/lib/firebase/server/adminDb"; +import { resolveCallerRole } from "@/lib/api/authz"; +import { requireSession } from "@/lib/api/requireSession"; +import { + serverGetDataByCalendarEventId, + serverGetFinalApproverEmail, + serverListResourceApproversByEmail, +} from "@/lib/firebase/server/adminDb"; import { executeXStateTransition } from "@/lib/stateMachines/xstateUtilsV5"; import { NextRequest, NextResponse } from "next/server"; @@ -36,17 +45,83 @@ function hasUnprocessedServices(bookingData: any): boolean { return false; } +const parseBookingResourceIds = (roomId: unknown): string[] => + String(roomId ?? "") + .split(",") + .map((resourceId) => resourceId.trim()) + .filter(Boolean); + +async function authorizeApproval( + email: string, + tenant: string, + booking: Record, +): Promise { + const role = await resolveCallerRole({ email, netId: email.split("@")[0] }, tenant); + if ( + role === PagePermission.ADMIN || + role === PagePermission.SUPER_ADMIN + ) { + return true; + } + + if (!booking.firstApprovedAt) { + return role === PagePermission.LIAISON; + } + + const normalizedEmail = email.trim().toLowerCase(); + const tenantFinalApprover = await serverGetFinalApproverEmail(tenant); + if (tenantFinalApprover?.trim().toLowerCase() === normalizedEmail) { + return true; + } + + const resourceIds = parseBookingResourceIds(booking.roomId); + if (resourceIds.length === 0) return false; + + const assignments = await serverListResourceApproversByEmail( + normalizedEmail, + tenant, + ); + const assignedResourceIds = new Set( + assignments.map((assignment) => assignment.resourceId), + ); + return resourceIds.every((resourceId) => assignedResourceIds.has(resourceId)); +} + /** * Checks if the XState result indicates a transition to Services Request parallel state * This typically happens for Media Commons bookings with requested services */ export async function POST(req: NextRequest) { - const { id, email } = await req.json(); + const session = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await req.json(); // Get tenant from x-tenant header, fallback to default tenant const tenant = req.headers.get("x-tenant") || DEFAULT_TENANT; + if (!isValidTenant(tenant)) { + return NextResponse.json({ error: "Invalid tenant" }, { status: 400 }); + } + const email = session.email; try { + const booking = await serverGetDataByCalendarEventId>( + TableNames.BOOKING, + id, + tenant, + ); + if (!booking) { + return NextResponse.json({ error: "Booking not found" }, { status: 404 }); + } + if (!(await authorizeApproval(email, tenant, booking))) { + return NextResponse.json( + { error: "You are not authorized to approve this booking" }, + { status: 403 }, + ); + } + console.log( `🎯 APPROVAL REQUEST [${tenant?.toUpperCase() || "UNKNOWN"}]:`, { diff --git a/booking-app/app/api/bookings/edit/route.ts b/booking-app/app/api/bookings/edit/route.ts index 17a669ac9..d94b1d1a8 100644 --- a/booking-app/app/api/bookings/edit/route.ts +++ b/booking-app/app/api/bookings/edit/route.ts @@ -251,7 +251,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) + .map((r: { roomId: string }) => r.roomId) .join(", "); console.log("✏️ EDIT: Deleting old calendar events"); @@ -343,16 +343,16 @@ export async function PUT(request: NextRequest) { // treat services as already decided (e.g. declined) and immediately // re-decline the edited request. // - // This is intentionally tenant-agnostic: clearing fields that don't exist - // is harmless, and it future-proofs tenants that add per-service approvals. - if (wasDeclined) { - updatedData.staffServiceApproved = null; - updatedData.equipmentServiceApproved = null; - updatedData.cateringServiceApproved = null; - updatedData.cleaningServiceApproved = null; - updatedData.securityServiceApproved = null; - updatedData.setupServiceApproved = null; - } + // Firestore fields are deleted (not nulled) after the booking update so + // restoreXStateFromFirestore does not rehydrate stale declined decisions. + const SERVICE_APPROVAL_FIELDS = [ + "staffServiceApproved", + "equipmentServiceApproved", + "cateringServiceApproved", + "cleaningServiceApproved", + "securityServiceApproved", + "setupServiceApproved", + ]; // If booking was declined, clear the declinedAt timestamp to ensure status shows as REQUESTED if (existingContents.declinedAt) { @@ -382,6 +382,15 @@ export async function PUT(request: NextRequest) { tenant, ); + if (wasDeclined) { + await serverDeleteFieldsByCalendarEventId( + TableNames.BOOKING, + newCalendarEventId, + SERVICE_APPROVAL_FIELDS, + tenant, + ); + } + // If booking was declined, trigger XState edit transition on the NEW calendarEventId // This moves the booking from DECLINED to REQUESTED state if (usesXState && wasDeclined) { diff --git a/booking-app/app/api/bookings/export/route.ts b/booking-app/app/api/bookings/export/route.ts index 0c1c45423..e436776e7 100644 --- a/booking-app/app/api/bookings/export/route.ts +++ b/booking-app/app/api/bookings/export/route.ts @@ -169,7 +169,7 @@ export async function GET(request: NextRequest) { : []; const rooms: RoomSetting[] = resourcesWithCorrectCalendarIds.map( (resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookings/modification/route.ts b/booking-app/app/api/bookings/modification/route.ts index 6c3c053cc..5248cf4d0 100644 --- a/booking-app/app/api/bookings/modification/route.ts +++ b/booking-app/app/api/bookings/modification/route.ts @@ -124,7 +124,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) + .map((r: { roomId: string }) => r.roomId) .join(", "); console.log("🔧 MODIFICATION: Deleting old calendar events"); diff --git a/booking-app/app/api/bookings/request-limits/route.ts b/booking-app/app/api/bookings/request-limits/route.ts new file mode 100644 index 000000000..5b1faf22c --- /dev/null +++ b/booking-app/app/api/bookings/request-limits/route.ts @@ -0,0 +1,77 @@ +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; +import { TableNames } from "@/components/src/policy"; +import { FormContextLevel } from "@/components/src/types"; +import { + enforceRequestLimits, + getRequestLimitRoleKey, +} from "@/lib/bookingRequestLimits"; +import { serverGetDocumentById } from "@/lib/firebase/server/adminDb"; +import { NextRequest, NextResponse } from "next/server"; + +import { extractTenantFromRequest } from "../shared"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const email = String(body?.email ?? "").trim(); + const bookingRoleField = String(body?.bookingRoleField ?? "").trim(); + const formContext = body?.formContext as FormContextLevel | undefined; + const roomIdsRaw = body?.roomIds; + + const roomIds: string[] = Array.isArray(roomIdsRaw) + ? roomIdsRaw + .map((x: unknown) => (x == null ? "" : String(x).trim())) + .filter((resourceId: string) => resourceId.length > 0) + : []; + + const tenant = extractTenantFromRequest(request); + + if (!tenant || !email || !bookingRoleField || roomIds.length === 0) { + return NextResponse.json( + { ok: false, error: "Missing tenant, email, role, or roomIds" }, + { status: 400 }, + ); + } + + const context = + formContext === FormContextLevel.VIP || + formContext === FormContextLevel.WALK_IN || + formContext === FormContextLevel.FULL_FORM || + formContext === FormContextLevel.EDIT || + formContext === FormContextLevel.MODIFICATION + ? formContext + : FormContextLevel.FULL_FORM; + + const limitRoleKey = getRequestLimitRoleKey(context, bookingRoleField); + + const schema = await serverGetDocumentById( + TableNames.TENANT_SCHEMA, + tenant, + tenant, + ); + + const enforcement = await enforceRequestLimits({ + tenant, + email, + bookingRoleField, + limitRoleKey, + selectedRoomIds: roomIds, + schema, + }); + + if (enforcement.ok === false) { + return NextResponse.json( + { ok: false, error: enforcement.message }, + { status: 429 }, + ); + } + + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("request-limits API error:", e); + return NextResponse.json( + { ok: false, error: "Unable to check request limits" }, + { status: 500 }, + ); + } +} diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index 36e0e1230..e34de94eb 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -19,6 +19,7 @@ import { BookingFormDetails, BookingOrigin, BookingStatusLabel, + FormContextLevel, RoomSetting, } from "@/components/src/types"; import { getSecondaryContactName } from "@/components/src/utils/formatters"; @@ -50,6 +51,11 @@ import { getAffiliationDisplayValues, getOtherDisplayFields, } from "./shared"; +import { + enforceRequestLimits, + getRequestLimitRoleKey, +} from "@/lib/bookingRequestLimits"; +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; // Common function to create XState data structure export function createXStateData( @@ -159,7 +165,7 @@ const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, @@ -227,9 +233,7 @@ async function createBookingCalendarEvent( throw Error(`calendarId not found for room ${room.roomId}`); } - const selectedRoomIds = selectedRooms.map( - (r: { roomId: number }) => r.roomId, - ); + const selectedRoomIds = selectedRooms.map((r) => r.roomId); const otherRoomEmails = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); @@ -565,6 +569,52 @@ export async function POST(request: NextRequest) { ); } + // Enforce per-resource request limits (per user email + role) + try { + const bookingRoleField = String(data?.role ?? "").trim(); + const limitRoleKey = getRequestLimitRoleKey( + FormContextLevel.FULL_FORM, + bookingRoleField, + ); + const selectedRoomIdsForLimits: string[] = Array.isArray(selectedRooms) + ? selectedRooms + .map((r: any) => (r?.roomId == null ? "" : String(r.roomId).trim())) + .filter((resourceId: string) => resourceId.length > 0) + : []; + + if ( + tenant && + email && + bookingRoleField && + selectedRoomIdsForLimits.length > 0 + ) { + const tenantSchema = await serverGetDocumentById( + TableNames.TENANT_SCHEMA, + tenant, + tenant, + ); + + const enforcement = await enforceRequestLimits({ + tenant, + email, + bookingRoleField, + limitRoleKey, + selectedRoomIds: selectedRoomIdsForLimits, + schema: tenantSchema, + }); + + if (enforcement.ok === false) { + return NextResponse.json( + { error: enforcement.message }, + { status: 429 }, + ); + } + } + } catch (e: any) { + console.error("Error enforcing request limits:", e); + // Fail open to avoid blocking bookings if enforcement throws unexpectedly + } + console.log("data", data); // Determine initial status and auto-approval using XState for ITP @@ -770,9 +820,10 @@ export async function POST(request: NextRequest) { // Generate Sequential ID early so it can be used in calendar description const sequentialId = await serverGetNextSequentialId("bookings", tenant); - const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) - .join(", "); + const selectedRoomIdsArray = selectedRooms + .map((r: { roomId: number | string }) => String(r.roomId).trim()) + .filter((resourceId: string) => resourceId.length > 0); + const selectedRoomIds = selectedRoomIdsArray.join(", "); // Build booking contents for description const startDateObj = new Date(bookingCalendarInfo.startStr); @@ -827,6 +878,7 @@ export async function POST(request: NextRequest) { const bookingData = { calendarEventId, roomId: selectedRoomIds, + roomIds: selectedRoomIdsArray, email, startDate: toFirebaseTimestampFromString(bookingCalendarInfo.startStr), endDate: toFirebaseTimestampFromString(bookingCalendarInfo.endStr), diff --git a/booking-app/app/api/bookings/shared.ts b/booking-app/app/api/bookings/shared.ts index 287f23e2e..0759fd21a 100644 --- a/booking-app/app/api/bookings/shared.ts +++ b/booking-app/app/api/bookings/shared.ts @@ -104,7 +104,7 @@ export const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookingsDirect/route.ts b/booking-app/app/api/bookingsDirect/route.ts index 5292b7702..8d766b513 100644 --- a/booking-app/app/api/bookingsDirect/route.ts +++ b/booking-app/app/api/bookingsDirect/route.ts @@ -9,13 +9,18 @@ import { notifyServiceApproversForRequestedServices, } from "@/components/src/server/serviceApproverNotifications"; import { getTenantEmailConfig } from "@/components/src/server/emails"; -import { BookingOrigin, BookingStatusLabel } from "@/components/src/types"; +import { + BookingOrigin, + BookingStatusLabel, + FormContextLevel, +} from "@/components/src/types"; import { getMediaCommonsServices, isMediaCommons, } from "@/components/src/utils/tenantUtils"; import { logServerBookingChange, + serverGetDocumentById, serverGetFinalApproverEmail, serverGetNextSequentialId, serverSaveDataToFirestore, @@ -29,6 +34,11 @@ import { getAffiliationDisplayValues, getOtherDisplayFields, } from "@/app/api/bookings/shared"; +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; +import { + enforceRequestLimits, + getRequestLimitRoleKey, +} from "@/lib/bookingRequestLimits"; // Helper function to extract tenant from request const extractTenantFromRequest = (request: NextRequest): string | undefined => { @@ -88,13 +98,53 @@ export async function POST(request: NextRequest) { const { departmentDisplay, schoolDisplay } = getAffiliationDisplayValues(data); const [room, ...otherRooms] = selectedRooms; - const selectedRoomIds = selectedRooms.map( - (r: { roomId: number }) => r.roomId, - ); + const selectedRoomIds = selectedRooms.map((r: { roomId: string }) => r.roomId); const otherRoomIds = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); + try { + const bookingRoleField = String(data?.role ?? "").trim(); + const limitRoleKey = getRequestLimitRoleKey( + FormContextLevel.FULL_FORM, + bookingRoleField, + ); + const selectedRoomIdsNums = selectedRoomIds + .map((id: number | string) => Number(id)) + .filter((n: number) => Number.isFinite(n)); + + if ( + tenant && + email && + bookingRoleField && + selectedRoomIdsNums.length > 0 + ) { + const tenantSchema = await serverGetDocumentById( + TableNames.TENANT_SCHEMA, + tenant, + tenant, + ); + + const enforcement = await enforceRequestLimits({ + tenant, + email, + bookingRoleField, + limitRoleKey, + selectedRoomIds: selectedRoomIdsNums, + schema: tenantSchema, + }); + + if (enforcement.ok === false) { + return NextResponse.json( + { result: "error", message: enforcement.message }, + { status: 429 }, + ); + } + } + } catch (e) { + console.error("Error enforcing request limits (bookingsDirect):", e); + } + // Determine booking status based on tenant and service requests let bookingStatus = BookingStatusLabel.APPROVED; let shouldInitializeXState = false; diff --git a/booking-app/app/api/cancel-processing/route.ts b/booking-app/app/api/cancel-processing/route.ts index efdd1f114..fdb459e42 100644 --- a/booking-app/app/api/cancel-processing/route.ts +++ b/booking-app/app/api/cancel-processing/route.ts @@ -55,7 +55,8 @@ export async function POST(req: NextRequest) { const roomIds = booking.roomId.split(",").map(x => x.trim()); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => roomIds.includes(`${resource.roomId}`), + (resource: any) => + roomIds.includes(String(resource.resourceId ?? resource.roomId)), ); console.log( @@ -64,7 +65,7 @@ export async function POST(req: NextRequest) { calendarEventId, roomIds, rooms: rooms.map((r: any) => ({ - roomId: r.roomId, + roomId: String(r.resourceId ?? r.roomId), calendarId: r.calendarId, })), }, diff --git a/booking-app/app/api/firestore/mutate/route.ts b/booking-app/app/api/firestore/mutate/route.ts index f93d9acde..0da58c736 100644 --- a/booking-app/app/api/firestore/mutate/route.ts +++ b/booking-app/app/api/firestore/mutate/route.ts @@ -48,6 +48,17 @@ export async function POST(req: NextRequest) { await colRef.doc(body.docId).update(data); return NextResponse.json({ ok: true }); } + if (body.op === "set") { + if (!body.docId) { + return NextResponse.json( + { error: "docId required" }, + { status: 400 }, + ); + } + const data = reviveValue(body.data) as FirebaseFirestore.DocumentData; + await colRef.doc(body.docId).set(data); + return NextResponse.json({ ok: true }); + } if (body.op === "delete") { if (!body.docId) { return NextResponse.json( diff --git a/booking-app/app/api/inviteUser/route.ts b/booking-app/app/api/inviteUser/route.ts index 554e54905..22bc2bc08 100644 --- a/booking-app/app/api/inviteUser/route.ts +++ b/booking-app/app/api/inviteUser/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest) { await inviteUserToCalendarEvent( calendarEventId, guestEmail, - parseInt(roomId, 10), + String(roomId), tenant, ); return NextResponse.json({ calendarEventId }, { status: 200 }); diff --git a/booking-app/app/api/safety_training_form/route.ts b/booking-app/app/api/safety_training_form/route.ts index 4add26094..3fccd2347 100644 --- a/booking-app/app/api/safety_training_form/route.ts +++ b/booking-app/app/api/safety_training_form/route.ts @@ -37,9 +37,7 @@ export async function GET(request: NextRequest) { let formId: string | null = null; if (resourceId) { - const resource = schema.resources?.find( - (r) => r.roomId?.toString() === resourceId.toString(), - ); + const resource = schema.resources?.find(r => r.resourceId === resourceId); const resourceForm = resource?.training?.formId; if (resourceForm) { formId = extractGoogleFormId(resourceForm); diff --git a/booking-app/app/api/syncCalendars/route.ts b/booking-app/app/api/syncCalendars/route.ts index ea39826c1..a3a6ce596 100644 --- a/booking-app/app/api/syncCalendars/route.ts +++ b/booking-app/app/api/syncCalendars/route.ts @@ -99,20 +99,20 @@ const findRoomIds = (event: any, resources: any[]): string => { r => r.calendarId === event.organizer.email, ); if (currentResource) { - roomIds.add(currentResource.roomId); + roomIds.add(String(currentResource.resourceId ?? currentResource.roomId)); } // Add other room IDs attendees.forEach((attendee: any) => { const resource = resources.find(r => r.calendarId === attendee.email); if (resource) { - roomIds.add(resource.roomId); + roomIds.add(String(resource.resourceId ?? resource.roomId)); } }); - // Convert to array, sort numerically, and join + // Resource IDs are opaque strings; numeric sorting is only a display aid. return Array.from(roomIds) - .sort((a, b) => parseInt(a) - parseInt(b)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .join(","); }; @@ -135,9 +135,9 @@ export async function POST(request: Request) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: resource.roomId.toString(), + id: String(resource.resourceId ?? resource.roomId), calendarId: resource.calendarId, - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), })); let totalNewBookings = 0; diff --git a/booking-app/app/api/syncSemesterPregameBookings/route.ts b/booking-app/app/api/syncSemesterPregameBookings/route.ts index 6637c9a9a..ba031862b 100644 --- a/booking-app/app/api/syncSemesterPregameBookings/route.ts +++ b/booking-app/app/api/syncSemesterPregameBookings/route.ts @@ -594,9 +594,9 @@ export async function POST(request: NextRequest) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: resource.roomId.toString(), + id: String(resource.resourceId ?? resource.roomId), calendarId: resource.calendarId, - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), })); if (testMode) { diff --git a/booking-app/clientInfo b/booking-app/clientInfo deleted file mode 100644 index 368be10fc..000000000 --- a/booking-app/clientInfo +++ /dev/null @@ -1 +0,0 @@ -o4Qd0YDOKtCR95EUyQ2Rbp5UbNUa:XkfbKenefg7ntnRR1D3r3nTo_lQa diff --git a/booking-app/components/src/client/README.md b/booking-app/components/src/client/README.md deleted file mode 100644 index 52ba76f5e..000000000 --- a/booking-app/components/src/client/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Client (our React code) - -This directory is where we store the source code for our client-side React apps. - -We have multiple directories in here because our app creates menu items that open multiple dialog windows. Each dialog opens a separate app, so each directory here represents its own distinct React app. Our webpack configuration will generate a separate bundle for each React app. - -## Requirements - -Each React app will need: -- an entrypoint, usually a file named `index.js`, that loads the app -- an HTML file that acts as a template, in which the bundled React app is loaded - -You'll need to declare the following in [webpack.config.js](../../webpack.config.js): -- **name**: just a name to print in the webpack console, e.g. 'CLIENT - Dialog Demo' -- **entry**: the path to the entry point for the app, e.g. './src/client/dialog-demo/index.js' -- **filename**: the name of the html file that is generated. The server code will reference this filename to load the app into a dialog window. E.g. 'dialog-demo' -- **template**: the path to the HTML template for the app, e.g. './src/client/dialog-demo/index.html' - - -### Adding or removing an entrypoint -Your app or use case may only require a single dialog or sidebar, or you may want to add more than are included in the sample app. - -To edit the entrypoints, you will need to: - -1. Create or remove the entrypoint directories in the client source code. For instance, you can remove `./src/client/sidebar-about-page` altogether, or copy it and modify the source code. See above [requirements](#requirements). - -2. Modify the server-side code to load the correct menu items and expose the correct public functions: - - [ui file](../server/ui.js) - - [index file](../server/index.js) - -3. Modify the `clientEntrypoints` config in the [webpack config file](../../webpack.config.js). diff --git a/booking-app/components/src/client/routes/admin/components/Approvers.tsx b/booking-app/components/src/client/routes/admin/components/Approvers.tsx index 710c24031..635b05ccc 100644 --- a/booking-app/components/src/client/routes/admin/components/Approvers.tsx +++ b/booking-app/components/src/client/routes/admin/components/Approvers.tsx @@ -1,7 +1,7 @@ -import { Stack, Typography } from "@mui/material"; +import { Typography } from "@mui/material"; import { EquipmentUsers } from "./EquipmentUsers"; import { Liaisons } from "./Liaisons"; -import { ServiceApproverUsers } from "./ServiceApproverUsers"; +import { ResourceSpecific } from "./ResourceSpecific"; export const Approvers = () => (
@@ -14,15 +14,8 @@ export const Approvers = () => ( - Service Approvers + Resource Approvers - - - - - - - - +
); diff --git a/booking-app/components/src/client/routes/admin/components/Ban.tsx b/booking-app/components/src/client/routes/admin/components/Ban.tsx index c91992336..cc9a5a71b 100644 --- a/booking-app/components/src/client/routes/admin/components/Ban.tsx +++ b/booking-app/components/src/client/routes/admin/components/Ban.tsx @@ -1,4 +1,3 @@ -// This is a wrapper for google.script.run that lets us use promises. import React, { useContext } from "react"; import { DatabaseContext } from "../../components/Provider"; diff --git a/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx b/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx new file mode 100644 index 000000000..c25cc450e --- /dev/null +++ b/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx @@ -0,0 +1,217 @@ +import { AddCircleOutline } from "@mui/icons-material"; +import { Box, IconButton, TextField, Typography } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2/Grid2"; +import { Timestamp } from "firebase/firestore"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + clientAddResourceApprover, + clientListResourceApprovers, + clientRemoveResourceApprover, +} from "@/lib/firebase/firebase"; +import { useTenantSchema } from "../../components/SchemaProvider"; +import ListTable from "../../components/ListTable"; +import { formatDate } from "../../../utils/date"; +import { TableNames } from "../../../../policy"; + +type ResourceApprover = { + id: string; + resourceId: string; + email: string; + createdAt?: Timestamp; +}; + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +type ResourceApproverSectionProps = { + resourceId: string; + approvers: ResourceApprover[]; + rowsRefresh: () => Promise; +}; + +const ResourceApproverSection = ({ + resourceId, + approvers, + rowsRefresh, +}: ResourceApproverSectionProps) => { + const { tenantId } = useTenantSchema(); + const [valueToAdd, setValueToAdd] = useState(""); + + const addApprover = async () => { + const normalizedEmail = normalizeEmail(valueToAdd); + if (!normalizedEmail) { + return; + } + + if (approvers.some((approver) => approver.email === normalizedEmail)) { + alert("This user is already a resource approver."); + return; + } + + try { + await clientAddResourceApprover(resourceId, normalizedEmail, tenantId); + setValueToAdd(""); + await rowsRefresh(); + } catch (addError) { + console.error("Failed to add resource approver:", addError); + alert("Failed to add resource approver."); + } + }; + + const rows = useMemo( + () => + approvers.map(({ id, email, createdAt }) => ({ + id, + email, + createdAt, + })), + [approvers], + ); + + const removeApprover = useCallback( + async (row: { [key: string]: string }) => { + await clientRemoveResourceApprover(resourceId, row.email, tenantId); + }, + [resourceId, tenantId], + ); + + const topRow = ( + + + Resource Approvers + + + + setValueToAdd(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void addApprover(); + } + }} + value={valueToAdd} + placeholder="Add email" + size="small" + /> + + void addApprover()} + color="primary" + sx={{ padding: 0 }} + disabled={!valueToAdd.trim()} + aria-label={`Add resource approver for ${resourceId}`} + > + + + + + ); + + return ( + + ); +}; + +export const ResourceSpecific = () => { + const { resources, tenantId } = useTenantSchema(); + const [approvers, setApprovers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const loadApprovers = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const data = await clientListResourceApprovers(tenantId); + setApprovers( + data.map((approver) => ({ + ...approver, + email: normalizeEmail(approver.email), + resourceId: approver.resourceId.trim(), + })), + ); + } catch (error) { + console.error("Failed to load resource approvers:", error); + setLoadError("Failed to load resource approvers."); + } finally { + setLoading(false); + } + }, [tenantId]); + + useEffect(() => { + void loadApprovers(); + }, [loadApprovers]); + + const approversByResource = useMemo(() => { + const result = new Map(); + approvers.forEach((approver) => { + const resourceApprovers = result.get(approver.resourceId) ?? []; + resourceApprovers.push(approver); + result.set(approver.resourceId, resourceApprovers); + }); + result.forEach((resourceApprovers) => + resourceApprovers.sort((a, b) => a.email.localeCompare(b.email)), + ); + return result; + }, [approvers]); + + if (loading) { + return ( + + Loading resource approvers... + + ); + } + + if (loadError) { + return ( + + {loadError} Refresh the page to retry. + + ); + } + + if (resources.length === 0) { + return ( + + No resources in tenant schema. + + ); + } + + return ( + + {resources.map((resource) => ( + + + {resource.resourceId} {resource.name} + + + + ))} + + ); +}; diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx index 9d5131d82..5b6d3d607 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx @@ -62,6 +62,13 @@ type RoomApplicationType = | "multi" | "specific"; +type RuntimeBlackoutPeriod = Omit & { + roomIds?: string[]; +}; + +const sortResourceIds = (resourceIds: string[]) => + resourceIds.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + /** * Check if two sorted arrays are equal */ @@ -74,16 +81,17 @@ function areSortedArraysEqual(arr1: T[], arr2: T[]): boolean { export default function BookingBlackoutPeriods() { const { roomSettings } = useContext(DatabaseContext); - const [blackoutPeriods, setBlackoutPeriods] = useState([]); + const [blackoutPeriods, setBlackoutPeriods] = useState< + RuntimeBlackoutPeriod[] + >([]); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string; } | null>(null); const [dialogOpen, setDialogOpen] = useState(false); - const [editingPeriod, setEditingPeriod] = useState( - null, - ); + const [editingPeriod, setEditingPeriod] = + useState(null); // Form state const [periodName, setPeriodName] = useState(""); @@ -91,7 +99,7 @@ export default function BookingBlackoutPeriods() { const [endDate, setEndDate] = useState(null); const [startTime, setStartTime] = useState(null); const [endTime, setEndTime] = useState(null); - const [selectedRooms, setSelectedRooms] = useState([]); + const [selectedRooms, setSelectedRooms] = useState([]); const [roomApplicationType, setRoomApplicationType] = useState("all"); @@ -106,6 +114,15 @@ export default function BookingBlackoutPeriods() { }); }, [roomSettings]); + const getCategoryResourceIds = (legacyRoomIds: readonly number[]) => { + const legacyIds = new Set(legacyRoomIds.map(String)); + return sortResourceIds( + roomSettings + .filter((room) => legacyIds.has(room.roomId)) + .map((room) => room.roomId), + ); + }; + const fetchBlackoutPeriods = async () => { try { const fetchedData = @@ -113,17 +130,22 @@ export default function BookingBlackoutPeriods() { TableNames.BLACKOUT_PERIODS, ); setBlackoutPeriods( - fetchedData.sort( - (a, b) => - a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), - ), + fetchedData + .map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })) + .sort( + (a, b) => + a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), + ), ); } catch (error) { console.error("Error fetching blackout periods:", error); } }; - const handleOpenDialog = (period?: BlackoutPeriod) => { + const handleOpenDialog = (period?: RuntimeBlackoutPeriod) => { if (period) { setEditingPeriod(period); setPeriodName(period.name); @@ -147,19 +169,13 @@ export default function BookingBlackoutPeriods() { if (period.roomIds && period.roomIds.length > 0) { // Check what type of room application this is - const allRoomIds = roomSettings - .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); - const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const eventRoomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const multiRoomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); + const allRoomIds = sortResourceIds( + roomSettings.map((room) => room.roomId), + ); + const periodRoomIds = sortResourceIds([...period.roomIds]); + const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); + const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); + const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); const isAllRooms = allRoomIds.length === periodRoomIds.length && @@ -259,32 +275,26 @@ export default function BookingBlackoutPeriods() { try { // Determine which rooms to apply the blackout to - let roomIds: number[] = []; + let roomIds: string[] = []; switch (roomApplicationType) { case "all": roomIds = roomSettings.map((room) => room.roomId); break; case "production": - roomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(PRODUCTION_ROOMS); break; case "event": - roomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(EVENT_ROOMS); break; case "multi": - roomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(MULTI_ROOMS); break; case "specific": roomIds = selectedRooms; break; } - const periodData: Omit = { + const periodData: Omit = { name: periodName.trim(), startDate: Timestamp.fromDate(startDate.toDate()), endDate: Timestamp.fromDate(endDate.toDate()), @@ -330,7 +340,7 @@ export default function BookingBlackoutPeriods() { } }; - const handleDeletePeriod = async (period: BlackoutPeriod) => { + const handleDeletePeriod = async (period: RuntimeBlackoutPeriod) => { if (!window.confirm(`Are you sure you want to delete "${period.name}"?`)) { return; } @@ -368,25 +378,17 @@ export default function BookingBlackoutPeriods() { return dateStr; }; - const getRoomNames = (period: BlackoutPeriod) => { + const getRoomNames = (period: RuntimeBlackoutPeriod) => { if (!period.roomIds || period.roomIds.length === 0) { return "All Rooms"; } // Check if this matches any of our predefined categories - const allRoomIds = roomSettings - .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); - const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const eventRoomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const multiRoomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); + const allRoomIds = sortResourceIds(roomSettings.map((room) => room.roomId)); + const periodRoomIds = sortResourceIds([...period.roomIds]); + const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); + const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); + const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); const isAllRooms = areSortedArraysEqual(allRoomIds, periodRoomIds); const isProductionRooms = areSortedArraysEqual( @@ -418,19 +420,13 @@ export default function BookingBlackoutPeriods() { }; const getProductionRoomNumbers = () => - PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(PRODUCTION_ROOMS).join(", "); const getEventRoomNumbers = () => - EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(EVENT_ROOMS).join(", "); const getMultiRoomNumbers = () => - MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(MULTI_ROOMS).join(", "); return ( @@ -631,7 +627,7 @@ export default function BookingBlackoutPeriods() { multiple value={selectedRooms} onChange={(e) => { - const value = e.target.value as number[]; + const value = e.target.value as string[]; setSelectedRooms(value); }} input={} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx index cca00ac49..79eae255d 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx @@ -34,10 +34,15 @@ const MenuProps = { export default function OperationalHours() { const { operationHours, roomSettings } = useContext(DatabaseContext); - const [specialHourRooms, setSpecialHourRooms] = useState( + const [specialHourRooms, setSpecialHourRooms] = useState( Array.from( - new Set(operationHours.map((x) => x.roomId).filter((x) => x != null)), - ).sort((a, b) => a - b), + new Set( + operationHours + .map((x) => x.roomId) + .filter((x) => x != null) + .map(String), + ), + ).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), ); // roomIds const handleChange = (event: SelectChangeEvent) => { @@ -45,11 +50,10 @@ export default function OperationalHours() { target: { value }, } = event; // On autofill we get a stringified value. - const list = - typeof value === "string" - ? value.split(",").map((x) => Number(x)) - : value; - setSpecialHourRooms(list.sort((a, b) => a - b)); + const list = typeof value === "string" ? value.split(",") : value; + setSpecialHourRooms( + list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), + ); }; return ( @@ -121,7 +125,7 @@ export default function OperationalHours() { x.day === day && x.roomId === roomId, + (x) => x.day === day && String(x.roomId) === roomId, )} roomId={roomId} key={day} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx index 619872b86..675c62f1d 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx @@ -18,11 +18,18 @@ const Row = styled(Box)` interface Props { day: Days; setting: OperationHours; - roomId?: number; + roomId?: string; } export default function OperationalHoursRow({ day, setting, roomId }: Props) { const { reloadOperationHours } = useContext(DatabaseContext); + const updateResourceOperationHours = updateOperationHours as unknown as ( + day: Days, + open: number, + close: number, + isClosed: boolean, + roomId?: string, + ) => Promise; const [closed, setClosed] = useState(setting?.isClosed || false); const [openDate, setOpenDate] = useState( @@ -34,7 +41,7 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleSwitch = (e) => { setClosed(!e.target.checked); - updateOperationHours( + updateResourceOperationHours( day, openDate.hour(), closeDate.hour(), @@ -45,12 +52,12 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleOpenChange = (e) => { setOpenDate(e); - updateOperationHours(day, e.$H, closeDate.hour(), closed, roomId); + updateResourceOperationHours(day, e.$H, closeDate.hour(), closed, roomId); }; const handleCloseChange = (e) => { setCloseDate(e); - updateOperationHours(day, openDate.hour(), e.$H, closed, roomId); + updateResourceOperationHours(day, openDate.hour(), e.$H, closed, roomId); }; // when we leave the page, the app reloads the newly set operation hours. diff --git a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx index 9fd20bfca..7ff329dfa 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx @@ -1,4 +1,5 @@ import { Department, Inputs, Role } from "@/components/src/types"; +import { toBookingCalendarStr } from "@/components/src/client/utils/date"; import { useContext } from "react"; import { BookingContext } from "../../booking/bookingProvider"; @@ -25,7 +26,7 @@ export default function useExistingBooking() { setDepartment(booking.department as Department); setRole(booking.role as Role); - const roomIds = booking.roomId.split(", ").map((x) => Number(x)); + const roomIds = booking.roomId.split(",").map((roomId) => roomId.trim()); const rooms = roomSettings.filter((roomSetting) => roomIds.includes(roomSetting.roomId), ); @@ -33,8 +34,8 @@ export default function useExistingBooking() { const start = booking.startDate.toDate(); const end = booking.endDate.toDate(); - const startStr = start.toISOString(); - const endStr = end.toISOString(); + const startStr = toBookingCalendarStr(start); + const endStr = toBookingCalendarStr(end); setBookingCalendarInfo({ start, end, diff --git a/booking-app/components/src/client/routes/booking/bookingProvider.tsx b/booking-app/components/src/client/routes/booking/bookingProvider.tsx index c26a6db7e..e93aa6152 100644 --- a/booking-app/components/src/client/routes/booking/bookingProvider.tsx +++ b/booking-app/components/src/client/routes/booking/bookingProvider.tsx @@ -161,9 +161,13 @@ export function BookingProvider({ children }) { const bookingStart = dayjs(bookingCalendarInfo.start); const bookingEnd = dayjs(bookingCalendarInfo.end); const selectedRoomIds = selectedRooms.map((room) => room.roomId); + const stringIdBlackoutPeriods = blackoutPeriods.map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })); const affectingPeriods = getAffectingBlackoutPeriods( - blackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, selectedRoomIds, diff --git a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx index 4076af768..076b38a32 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx @@ -51,11 +51,11 @@ export default function BookingFormMediaServices(props: Props) { // Check for specific room services selectedRooms.forEach((room) => { if (showStaffing) { - if (room.roomId === 103) { + if (room.roomId === "103") { options.push(MediaServices.AUDIO_TECH_103); options.push(MediaServices.LIGHTING_TECH_103); } - if (room.roomId === 230) { + if (room.roomId === "230") { options.push(MediaServices.AUDIO_TECH_230); } if (room.services?.includes("campus-media")) { diff --git a/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx b/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx index 3408a6908..a78e53963 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx @@ -48,13 +48,13 @@ export default function BookingSelection() { Date: -

{new Date(bookingCalendarInfo.startStr).toLocaleDateString()}

+

{bookingCalendarInfo.start.toLocaleDateString()}

Time: -

{`${formatTimeAmPm( - new Date(bookingCalendarInfo.startStr), - )} - ${formatTimeAmPm(new Date(bookingCalendarInfo.endStr))}`}

+

{`${formatTimeAmPm(bookingCalendarInfo.start)} - ${formatTimeAmPm( + bookingCalendarInfo.end, + )}`}

diff --git a/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx b/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx index ed3735c23..fd6e48e40 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx @@ -18,6 +18,7 @@ import { BookingContext } from "../bookingProvider"; import useCalculateOverlap from "../hooks/useCalculateOverlap"; import useCheckAutoApproval from "../hooks/useCheckAutoApproval"; import useCheckDurationLimits from "../hooks/useCheckDurationLimits"; +import useCheckRequestLimits from "../hooks/useCheckRequestLimits"; import { defaultSafetyTrainingInfoUrl, useTenantSchema, @@ -47,6 +48,8 @@ export default function BookingStatusBar({ formContext, ...props }: Props) { isVIP, ); const { durationError } = useCheckDurationLimits(isWalkIn, isVIP); + const { requestLimitError, requestLimitChecking } = + useCheckRequestLimits(formContext); const { bookingCalendarInfo, selectedRooms, @@ -90,6 +93,8 @@ export default function BookingStatusBar({ formContext, ...props }: Props) { needsSafetyTraining || isInBlackoutPeriod || durationError !== null || + requestLimitError != null || + requestLimitChecking || (bookingCalendarInfo != null && selectedRooms.length > 0); // order of precedence matters @@ -120,6 +125,14 @@ export default function BookingStatusBar({ formContext, ...props }: Props) { severity: "error", variant: "filled", }; + if (requestLimitChecking) + return { + btnDisabled: true, + btnDisabledMessage: "Checking request limits…", + message:

Checking request limits…

, + severity: "info", + variant: "filled", + }; if (needsSafetyTraining) return { btnDisabled: true, @@ -168,6 +181,14 @@ export default function BookingStatusBar({ formContext, ...props }: Props) { ), severity: "error", }; + if (requestLimitError) + return { + btnDisabled: true, + btnDisabledMessage: requestLimitError, + message:

{requestLimitError}

, + severity: "error", + variant: "filled", + }; if (durationError) return { btnDisabled: true, diff --git a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx index 66aaefe33..48e99fb79 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx @@ -29,7 +29,7 @@ import { getBlackoutTimeRangeForDate } from "../../../../utils/blackoutUtils"; import { DatabaseContext } from "../../components/Provider"; import { BookingContext } from "../bookingProvider"; import { useBookingDateRestrictions } from "../hooks/useBookingDateRestrictions"; -import { TIMEZONE } from "../../../utils/date"; +import { TIMEZONE, toBookingCalendarStr } from "../../../utils/date"; import { DEFAULT_START_HOUR } from "../utils/getStartHour"; import { DEFAULT_SLOT_UNIT } from "../utils/getSlotUnit"; import { buildBlockPastTimes } from "../utils/buildBlockPastTimes"; @@ -141,11 +141,17 @@ export default function CalendarVerticalResource({ const resources = useMemo( () => - rooms.map((room) => ({ - id: `${room.roomId}`, - title: `${room.roomId} ${room.name}`, - index: Number(room.roomId), - })), + [...rooms] + .sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), + ) + .map((room, index) => ({ + id: room.roomId, + title: `${room.roomId} ${room.name}`, + index, + })), [rooms], ); @@ -169,7 +175,7 @@ export default function CalendarVerticalResource({ start: blackoutRange.start.toISOString(), end: blackoutRange.end.toISOString(), id: `blackout-${room.roomId}-${period.id}`, - resourceId: `${room.roomId}`, + resourceId: room.roomId, title: blackoutRange.title, overlap: false, display: "background", @@ -208,12 +214,11 @@ export default function CalendarVerticalResource({ start: bookingCalendarInfo.startStr, end: bookingCalendarInfo.endStr, id: room.roomId + bookingCalendarInfo.startStr, - resourceId: `${room.roomId}`, + resourceId: room.roomId, title: NEW_TITLE_TAG, overlap: true, durationEditable: true, - startEditable: - formContext !== FormContextLevel.MODIFICATION || isAdmin, + startEditable: formContext !== FormContextLevel.MODIFICATION || isAdmin, groupId: "new", url: `${index}:${rooms.length}`, // some hackiness to let us render multiple events visually as one big block })); @@ -241,11 +246,9 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { - const roomId = parseInt(selectedResourceId); - // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - roomId, + selectedResourceId, ]); if (inBlackout) { @@ -267,11 +270,9 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { - const roomId = parseInt(selectedResourceId); - // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - roomId, + selectedResourceId, ]); if (inBlackout) { @@ -315,11 +316,17 @@ export default function CalendarVerticalResource({ // if change event duration via dragging edges or drag event block to move const handleEventEdit = (info: EventResizeDoneArg | EventDropArg) => { - // Always allow modification of end time, even for past events + if (info.event.start == null || info.event.end == null) { + return; + } + + // Always allow modification of end time, even for past events. + // Normalize to Eastern local strings so resize/drag stays consistent with + // FullCalendar's timeZone and avoids UTC ISO strings corrupting start time. setBookingCalendarInfo({ - startStr: info.event.startStr, + startStr: toBookingCalendarStr(info.event.start), start: info.event.start, - endStr: info.event.endStr, + endStr: toBookingCalendarStr(info.event.end), end: info.event.end, allDay: false, jsEvent: info.jsEvent, @@ -407,9 +414,7 @@ export default function CalendarVerticalResource({ googleCalendarPlugin, interactionPlugin, ]} - selectable={ - formContext !== FormContextLevel.MODIFICATION || isAdmin - } + selectable={formContext !== FormContextLevel.MODIFICATION || isAdmin} select={handleEventSelect} selectAllow={handleEventSelecting} selectOverlap={handleSelectOverlap} diff --git a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx index 36e0922fd..2bb30d207 100644 --- a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx +++ b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx @@ -32,13 +32,18 @@ export const SelectRooms = ({ } = useContext(BookingContext); const { isBookingTimeInBlackout } = useBookingDateRestrictions(); const { resources, calendarConfig } = useTenantSchema(); - const selectedIds = selected.map((room) => room.roomId); + const selectedIds = selected.map((room) => String(room.roomId)); const allowMultipleResourceSelect = calendarConfig?.multipleResourceSelect ?? true; // Sort rooms by room number for consistent display order const sortedRooms = useMemo( - () => [...allRooms].sort((a, b) => a.roomId - b.roomId), + () => + [...allRooms].sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), + ), [allRooms], ); @@ -53,7 +58,7 @@ export const SelectRooms = ({ // Check if a room is in blackout for the selected booking time const isRoomInBlackout = ( - roomId: number, + roomId: string, ): { inBlackout: boolean; periods: any[] } => { if (!bookingCalendarInfo) return { inBlackout: false, periods: [] }; @@ -69,7 +74,7 @@ export const SelectRooms = ({ }; // walk-ins can only book 1 room unless it's 2 ballroom bays (221-224) - const isDisabled = (roomId: number) => { + const isDisabled = (roomId: string) => { if (!allowMultipleResourceSelect) { if (selectedIds.length === 0) return false; if (selectedIds.includes(roomId)) return false; @@ -85,9 +90,11 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (r: any) => r.roomId === selectedIds[0], + (resource) => resource.resourceId === selectedIds[0], + ); + const currentRoom = resources.find( + (resource) => resource.resourceId === roomId, ); - const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return false; @@ -95,7 +102,7 @@ export const SelectRooms = ({ return true; }; - const getDisabledReason = (roomId: number): string | null => { + const getDisabledReason = (roomId: string): string | null => { const blackoutInfo = isRoomInBlackout(roomId); if (blackoutInfo.inBlackout) { const periodNames = blackoutInfo.periods.map((p) => p.name).join(", "); @@ -111,9 +118,11 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (r: any) => r.roomId === selectedIds[0], + (resource) => resource.resourceId === selectedIds[0], + ); + const currentRoom = resources.find( + (resource) => resource.resourceId === roomId, ); - const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return null; @@ -124,22 +133,27 @@ export const SelectRooms = ({ const handleCheckChange = (e: any, room: RoomSetting) => { const newVal: boolean = e.target.checked; + const normalizedRoom = { ...room, roomId: String(room.roomId) }; setSelected((prev: RoomSetting[]) => { if (newVal) { if ( !allowMultipleResourceSelect && prev.length > 0 && - !prev.some((r) => r.roomId === room.roomId) + !prev.some((r) => String(r.roomId) === normalizedRoom.roomId) ) { return prev; } - const newSelection = [...prev, room].sort( - (a, b) => a.roomId - b.roomId, + const newSelection = [...prev, normalizedRoom].sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), ); return newSelection; } - const newSelection = prev.filter((r) => r.roomId !== room.roomId); + const newSelection = prev.filter( + (r) => String(r.roomId) !== normalizedRoom.roomId, + ); if (newSelection.length === 0) { setBookingCalendarInfo(null); } @@ -150,17 +164,20 @@ export const SelectRooms = ({ return ( {sortedRooms.map((room: RoomSetting) => { - const disabled = isDisabled(room.roomId); - const disabledReason = getDisabledReason(room.roomId); + const roomId = String(room.roomId); + const disabled = isDisabled(roomId); + const disabledReason = getDisabledReason(roomId); const checkbox = ( handleCheckChange(e, room)} icon={ - allowMultipleResourceSelect ? undefined : + allowMultipleResourceSelect ? undefined : ( + + ) } checkedIcon={ allowMultipleResourceSelect ? undefined : diff --git a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx index 5256d6531..845f67a1b 100644 --- a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx @@ -47,7 +47,7 @@ export default function SelectRoomPage({ // Convert schema resources to RoomSetting format for compatibility const convertedResources = resources.map((resource) => ({ ...resource, - roomId: resource.roomId, + roomId: resource.resourceId, name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, @@ -68,12 +68,7 @@ export default function SelectRoomPage({ const allRooms = !isWalkIn ? convertedResources - : convertedResources.filter((room) => { - const resource = schema.resources.find( - (r: any) => r.roomId === room.roomId, - ); - return resource?.isWalkIn || false; - }); + : convertedResources.filter((room) => room.isWalkIn); return allRooms; }, [schema.resources, isWalkIn]); diff --git a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx index 6c87fc561..1771db171 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx @@ -16,6 +16,15 @@ export const useBookingDateRestrictions = () => { return blackoutPeriods.filter((period) => period.isActive); }, [blackoutPeriods]); + const stringIdBlackoutPeriods = useMemo( + () => + activeBlackoutPeriods.map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })), + [activeBlackoutPeriods], + ); + const isDateDisabled = (date: Dayjs) => { // Always disable past dates if (date.isBefore(dayjs(), "day")) { @@ -33,8 +42,12 @@ export const useBookingDateRestrictions = () => { if (roomSettings && roomSettings.length > 0 && period.roomIds) { const allRoomIds = roomSettings .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); + .sort((a, b) => + String(a).localeCompare(String(b), undefined, { numeric: true }), + ); + const periodRoomIds = period.roomIds + .map(String) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); const isAllRooms = allRoomIds.length === periodRoomIds.length && allRoomIds.every((id, index) => id === periodRoomIds[index]); @@ -48,7 +61,9 @@ export const useBookingDateRestrictions = () => { }; // Check if a specific date is disabled for specific rooms - const isDateDisabledForRooms = (date: Dayjs, roomIds: number[]) => { + const isDateDisabledForRooms = (date: Dayjs, roomIds: string[]) => { + const normalizedRoomIds = roomIds.map(String); + // Always disable past dates if (date.isBefore(dayjs(), "day")) { return true; @@ -63,7 +78,10 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - return roomIds.some((roomId) => period.roomIds.includes(roomId)); + const periodRoomIds = period.roomIds.map(String); + return normalizedRoomIds.some((roomId) => + periodRoomIds.includes(roomId), + ); } // If no roomIds, treat as global blackout that applies to all rooms @@ -78,8 +96,9 @@ export const useBookingDateRestrictions = () => { }); // Get blackout periods that apply to specific rooms for a specific date - const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: number[]) => + const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: string[]) => activeBlackoutPeriods.filter((period) => { + const normalizedRoomIds = roomIds.map(String); const blackoutRange = getBlackoutTimeRangeForDate(period, date); if (!blackoutRange) { return false; // Date not in blackout period @@ -87,7 +106,10 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - return roomIds.some((roomId) => period.roomIds.includes(roomId)); + const periodRoomIds = period.roomIds.map(String); + return normalizedRoomIds.some((roomId) => + periodRoomIds.includes(roomId), + ); } // Include global blackout periods (those without roomIds) @@ -98,17 +120,17 @@ export const useBookingDateRestrictions = () => { const isBookingTimeInBlackout = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: number[], + roomIds: string[], ): { inBlackout: boolean; affectedPeriods: any[] } => { if (!roomIds || roomIds.length === 0) { return { inBlackout: false, affectedPeriods: [] }; } const affectedPeriods = getAffectingBlackoutPeriods( - activeBlackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, - roomIds, + roomIds.map(String), ); return { @@ -121,17 +143,17 @@ export const useBookingDateRestrictions = () => { const getBlackoutPeriodsForBookingTime = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: number[], + roomIds: string[], ) => getAffectingBlackoutPeriods( - activeBlackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, - roomIds, + roomIds.map(String), ); const shouldDisableDate = (date: Dayjs) => isDateDisabled(date); - const shouldDisableDateForRooms = (date: Dayjs, roomIds: number[]) => + const shouldDisableDateForRooms = (date: Dayjs, roomIds: string[]) => isDateDisabledForRooms(date, roomIds); return { diff --git a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx index 4d5a166a9..d6e36e707 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx @@ -21,10 +21,10 @@ export default function useCalculateOverlap() { calendarEventId = segments[segments.length - 1]; } - const selectedRoomIds = selectedRooms.map((x) => x.roomId); + const selectedRoomIds = selectedRooms.map((x) => String(x.roomId)); return existingCalendarEvents .map((event) => { - if (!selectedRoomIds.includes(Number(event.resourceId))) return false; + if (!selectedRoomIds.includes(String(event.resourceId))) return false; // for edit/modification mode, don't overlap with existing booking if ( calendarEventId && diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx index 0f97218cf..c39a90358 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx @@ -13,7 +13,7 @@ import { BookingContext } from "../bookingProvider"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; export function selectedAutoApprovalRooms( - selectedRoomIds: number[], + selectedRoomIds: string[], selectedRooms: any[], ) { if (selectedRoomIds.length < 2) return true; @@ -47,7 +47,10 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { useEffect(() => { // For ITP and Media Commons tenants, use XState machine for auto-approval logic - if (schema.tenantId === TENANTS.ITP || isMediaCommonsTenant(schema.tenantId)) { + if ( + schema.tenantId === TENANTS.ITP || + isMediaCommonsTenant(schema.tenantId) + ) { console.log( `🎭 CLIENT-SIDE XSTATE CHECK [${schema.tenantId?.toUpperCase()}]:`, { @@ -72,7 +75,9 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { try { // Choose the appropriate machine based on tenant const machine = - schema.tenantId === TENANTS.ITP ? itpBookingMachine : mcBookingMachine; + schema.tenantId === TENANTS.ITP + ? itpBookingMachine + : mcBookingMachine; // For Media Commons, prepare services data const servicesRequested = isMediaCommonsTenant(schema.tenantId) diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx index e05bf20c3..370182650 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx @@ -5,7 +5,7 @@ import { Role } from "../../../../types"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; interface DurationLimitError { - roomId: number; + roomId: string; roomName: string; currentDuration: number; maxDuration: number; diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx new file mode 100644 index 000000000..6741c1147 --- /dev/null +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx @@ -0,0 +1,127 @@ +import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; +import { FormContextLevel } from "@/components/src/types"; +import { isValidNetIdFormat } from "@/components/src/utils/validationHelpers"; +import { useParams, usePathname } from "next/navigation"; +import { useContext, useEffect, useRef, useState } from "react"; +import { DatabaseContext } from "../../components/Provider"; +import { useTenantSchema } from "../../components/SchemaProvider"; +import { BookingContext } from "../bookingProvider"; + +export default function useCheckRequestLimits(formContext: FormContextLevel) { + const params = useParams(); + const pathname = usePathname(); + const tenant = (params?.tenant as string) || DEFAULT_TENANT; + const { userEmail } = useContext(DatabaseContext); + const { bookingCalendarInfo, selectedRooms, role, formData } = + useContext(BookingContext); + const schema = useTenantSchema(); + + const [requestLimitError, setRequestLimitError] = useState( + null, + ); + const [requestLimitChecking, setRequestLimitChecking] = useState(false); + + const abortRef = useRef(null); + + const isSelectRoomPage = pathname.endsWith("/selectRoom"); + const isWalkIn = formContext === FormContextLevel.WALK_IN; + const isVIP = formContext === FormContextLevel.VIP; + + useEffect(() => { + abortRef.current?.abort(); + setRequestLimitError(null); + + if ( + !isSelectRoomPage || + !schema.tenantId || + !role || + selectedRooms.length === 0 || + bookingCalendarInfo == null + ) { + setRequestLimitChecking(false); + return; + } + + let email: string | undefined; + if (isWalkIn || isVIP) { + const netIdToUse = formData?.walkInNetId || formData?.netId; + if (netIdToUse && isValidNetIdFormat(netIdToUse)) { + email = `${netIdToUse}@nyu.edu`; + } + } else { + email = userEmail || formData?.missingEmail; + } + + const roomIds = selectedRooms.map((room) => room.roomId); + + if (!email?.trim() || roomIds.length === 0) { + setRequestLimitChecking(false); + return; + } + + const bookingRoleField = String(role); + + const controller = new AbortController(); + abortRef.current = controller; + setRequestLimitChecking(true); + + fetch("/api/bookings/request-limits", { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + "x-tenant": tenant, + }, + body: JSON.stringify({ + email: email.trim(), + bookingRoleField, + formContext, + roomIds, + }), + }) + .then(async (res) => { + if (controller.signal.aborted) return; + if (res.status === 429) { + const body = await res.json().catch(() => ({})); + setRequestLimitError( + typeof body?.error === "string" + ? body.error + : "Request limit reached for one of the selected spaces.", + ); + return; + } + if (!res.ok) { + // Fail open on unexpected errors so booking is not blocked by UX check alone + setRequestLimitError(null); + return; + } + setRequestLimitError(null); + }) + .catch((e) => { + if (controller.signal.aborted) return; + console.error("request limits check failed:", e); + setRequestLimitError(null); + }) + .finally(() => { + if (!controller.signal.aborted) setRequestLimitChecking(false); + }); + + return () => controller.abort(); + }, [ + isSelectRoomPage, + schema.tenant, + tenant, + userEmail, + role, + selectedRooms, + bookingCalendarInfo, + formContext, + formData?.walkInNetId, + formData?.netId, + formData?.missingEmail, + isWalkIn, + isVIP, + ]); + + return { requestLimitError, requestLimitChecking }; +} diff --git a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts index d64a084de..8f37858ef 100644 --- a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts +++ b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts @@ -25,7 +25,7 @@ export interface BlockPastTimeEvent { * as a parameter so unit tests can control the clock. */ export function buildBlockPastTimes( - rooms: Array<{ roomId: number | string }>, + rooms: Array<{ roomId: string }>, dateView: Date, startHour: string | undefined, slotUnit: number, @@ -96,7 +96,7 @@ export function buildBlockPastTimes( start: blockStart.toISOString(), end: blockEnd.toISOString(), id: `${room.roomId}bg`, - resourceId: `${room.roomId}`, + resourceId: String(room.roomId), overlap: false, display: "background", classNames: ["disabled"], diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index be52361f1..69b760fde 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -314,6 +314,7 @@ export const DatabaseProvider = ({ const rooms: RoomSetting[] = schemaContext.resources .map((resource) => ({ ...resource, + roomId: resource.resourceId, capacity: String(resource.capacity), needsSafetyTraining: resource.training?.required || false, trainingFormUrl: resource.training?.formId || undefined, @@ -323,7 +324,9 @@ export const DatabaseProvider = ({ isEquipment: resource.isEquipment || false, services: resource.services || [], })) - .sort((a, b) => a.roomId - b.roomId); + .sort((a, b) => + a.roomId.localeCompare(b.roomId, undefined, { numeric: true }), + ); setRoomSettings(rooms); } }, [schemaContext?.resources]); diff --git a/booking-app/components/src/client/routes/components/Table.tsx b/booking-app/components/src/client/routes/components/Table.tsx index eea569af7..09a8cd22e 100644 --- a/booking-app/components/src/client/routes/components/Table.tsx +++ b/booking-app/components/src/client/routes/components/Table.tsx @@ -16,8 +16,11 @@ const Clip = styled(Box)` overflow-x: auto; `; +const DEFAULT_TABLE_BORDER = "#e3e3e3"; +const DEFAULT_TABLE_HEADER_BACKGROUND = "rgba(33, 33, 33, 0.08)"; + const TableCustom = styled(MuiTable)(({ theme }) => ({ - border: `1px solid ${theme.palette.custom.border}`, + border: `1px solid ${theme.palette.custom?.border ?? DEFAULT_TABLE_BORDER}`, borderCollapse: "separate", borderRadius: "0px 0px 4px 4px", // overflowX: "scroll", @@ -32,7 +35,8 @@ const TableCustom = styled(MuiTable)(({ theme }) => ({ })); const ShadedHeader = styled(TableHead)(({ theme }) => ({ - backgroundColor: theme.palette.custom.gray, + backgroundColor: + theme.palette.custom?.gray ?? DEFAULT_TABLE_HEADER_BACKGROUND, })); export const TableTopRow = styled(MuiTable)` diff --git a/booking-app/components/src/client/routes/components/schemaTypes.ts b/booking-app/components/src/client/routes/components/schemaTypes.ts index 1ec6bb2f8..d6c262763 100644 --- a/booking-app/components/src/client/routes/components/schemaTypes.ts +++ b/booking-app/components/src/client/routes/components/schemaTypes.ts @@ -14,9 +14,6 @@ export type Attestation = { html: string; }; -/** @deprecated Use Attestation */ -export type Agreement = Attestation; - export type StaffingSection = { name: string; indexes: number[]; @@ -28,16 +25,32 @@ export type ResourceTraining = { infoUrl?: string; }; +export type RequestLimitPeriod = "perDay" | "perWeek" | "perMonth" | "perSemester"; + +/** Keys in `resource.requestLimits` — one bucket per base role (VIP / walk-in share the same cap). */ +export type RequestLimitBucketKey = "admin" | "faculty" | "student"; + +export type RequestLimits = Partial< + Record>> +>; + export type Resource = { capacity: number; name: string; - roomId: number; + resourceId: string; isEquipment: boolean; calendarId: string; training?: ResourceTraining; isWalkIn: boolean; isWalkInCanBookTwo: boolean; services: string[]; + /** + * Limit how many requests a user can make per period for this resource. + * Convention: `-1` (or missing) means “unlimited”. + * + * Shape: each period has only `admin`, `faculty`, `student` — counts include all booking origins. + */ + requestLimits?: RequestLimits; autoApproval?: { shouldAutoApprove?: boolean; minHour?: { @@ -93,6 +106,14 @@ export type TimeSensitiveRequestWarning = { policyLink?: string; }; +export type TermRange = [number, number]; // [startMonth, endMonth], 1-12 inclusive + +export type TermConfig = { + fallTerm: TermRange; // e.g. [9, 12] + springTerm: TermRange; // e.g. [1, 5] + summerTerm: TermRange; // e.g. [6, 8] +}; + export type ContextLabels = { user: string; worker: string; @@ -101,9 +122,6 @@ export type ContextLabels = { admin: string; }; -/** @deprecated Use ContextLabels */ -export type PermissionLabels = ContextLabels; - export type TenantBranding = { name: string; logo: string; @@ -190,6 +208,8 @@ export type SchemaContextType = { preApproved: boolean; }; }; + /** Term/semester configuration for "perSemester" request limits */ + termConfig?: TermConfig; calendarConfig?: { startHour?: Record; slotUnit?: Record; @@ -223,13 +243,10 @@ export const defaultAttestation: Attestation = { html: "", }; -/** @deprecated Use defaultAttestation */ -export const defaultAgreement = defaultAttestation; - export const defaultResource: Resource = { capacity: 0, name: "", - roomId: 0, + resourceId: "", isEquipment: false, calendarId: "", training: { @@ -240,6 +257,12 @@ export const defaultResource: Resource = { isWalkIn: false, isWalkInCanBookTwo: false, services: [], + requestLimits: { + perDay: { admin: -1, faculty: -1, student: -1 }, + perWeek: { admin: -1, faculty: -1, student: -1 }, + perMonth: { admin: -1, faculty: -1, student: -1 }, + perSemester: { admin: -1, faculty: -1, student: -1 }, + }, autoApproval: { shouldAutoApprove: false, minHour: { admin: -1, faculty: -1, student: -1 }, @@ -350,6 +373,11 @@ export const defaultScheme: Omit = { declinedGracePeriod: 24, interimHighlightThresholdHours: 18, autoCancel: false, + termConfig: { + fallTerm: [9, 12], + springTerm: [1, 5], + summerTerm: [6, 8], + }, calendarConfig: { startHour: { student: "09:00:00", diff --git a/booking-app/components/src/client/routes/super/schemaEditor.tsx b/booking-app/components/src/client/routes/super/schemaEditor.tsx index f14fbd327..40f991141 100644 --- a/booking-app/components/src/client/routes/super/schemaEditor.tsx +++ b/booking-app/components/src/client/routes/super/schemaEditor.tsx @@ -478,7 +478,7 @@ function AttestationsSection({ } // ─── Section: Resources ─── -function ResourceEditor({ +export function ResourceEditor({ resource, index, onUpdate, @@ -505,7 +505,7 @@ function ResourceEditor({ {resource.name || `Resource ${index + 1}`} - {resource.roomId ? ` (Room ${resource.roomId})` : ""} + {resource.resourceId ? ` (${resource.resourceId})` : ""} @@ -518,12 +518,11 @@ function ResourceEditor({ size="small" /> set("roomId", Number(e.target.value))} + label="Resource ID" + value={resource.resourceId ?? ""} + onChange={(e) => set("resourceId", e.target.value)} size="small" - sx={{ width: 120 }} + sx={{ width: 180 }} /> {resources.map((r, i) => ( + formatInTimeZone(date, TIMEZONE, "yyyy-MM-dd'T'HH:mm:ss"); + export const formatDate = ( oldDate: | Date diff --git a/booking-app/components/src/policy.ts b/booking-app/components/src/policy.ts index 1c1cb8edd..c094a85cc 100644 --- a/booking-app/components/src/policy.ts +++ b/booking-app/components/src/policy.ts @@ -6,6 +6,7 @@ import { getApprovedCcEmail, getCanceledCcEmail } from "./tenantPolicy"; export enum TableNames { ADMINS = "usersAdmin", APPROVERS = "usersApprovers", + RESOURCE_APPROVERS = "usersResourceApprovers", BANNED = "usersBanned", BOOKING = "bookings", BOOKING_TYPES = "bookingTypes", @@ -45,6 +46,7 @@ export const getTenantCollectionName = ( "settings", "usersWhitelist", "usersApprovers", + "usersResourceApprovers", "usersRights", "counters", ]; @@ -100,6 +102,15 @@ export enum ApproverLevel { EQUIPMENT = 3, } +export const getResourceApproverDocumentId = ( + resourceId: string, + email: string, +): string => { + const encodedResourceId = encodeURIComponent(resourceId.trim()); + const encodedEmail = encodeURIComponent(email.trim().toLowerCase()); + return `${encodedResourceId.length}-${encodedResourceId}${encodedEmail}`; +}; + /** ******** CONTACTS ************/ export const clientGetFinalApproverEmail = async (): Promise => { diff --git a/booking-app/components/src/server/.babelrc b/booking-app/components/src/server/.babelrc deleted file mode 100644 index 2b14d1bc8..000000000 --- a/booking-app/components/src/server/.babelrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "presets": [ - [ - "@babel/env", - { - "modules": false - } - ] - ], - "plugins": [ - "transform-es3-property-literals", - "transform-es3-member-expression-literals", - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-transform-object-assign", - "@babel/plugin-proposal-optional-chaining" - ] -} diff --git a/booking-app/components/src/server/README.md b/booking-app/components/src/server/README.md deleted file mode 100644 index dfce47b4c..000000000 --- a/booking-app/components/src/server/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Server (our Google Apps Script code) - -This directory is where we store the source code for our Google Apps Script code, which runs on Google's servers. - -## Requirements - -The server code will need: -- an entrypoint, usually a file named `index.js`, that loads the app. -- the entry point will need to declare any public functions by attaching them to the "`global`" object, like this: - ```javascript - global.onOpen = someFunction; - ``` - -See the [ui.js](./ui.js) file for how to open menu items and set up the development settings properly. - -## Build - -Server-side code here will be compiled using settings that are compatible with the V8 or Rhino runtime (https://developers.google.com/apps-script/guides/v8-runtime). Update the [appsscript.json](../../appsscript.json) file as needed to switch runtimes. - diff --git a/booking-app/components/src/server/admin.ts b/booking-app/components/src/server/admin.ts index e1baf5916..8aed095db 100644 --- a/booking-app/components/src/server/admin.ts +++ b/booking-app/components/src/server/admin.ts @@ -6,6 +6,7 @@ import { serverGetDataByCalendarEventId, serverGetDocumentById, serverGetFinalApproverEmail, + serverResolveResourceApproverEmails, serverUpdateInFirestore, } from "@/lib/firebase/server/adminDb"; import { Timestamp } from "firebase-admin/firestore"; @@ -34,6 +35,55 @@ interface HistoryItem { note?: string; } +const parseBookingResourceIds = (roomId: unknown): string[] => + String(roomId ?? "") + .split(",") + .map((resourceId) => resourceId.trim()) + .filter(Boolean); + +const resolveBookingResourceApproverEmails = async ( + roomId: unknown, + tenant?: string, +): Promise => { + const resourceIds = parseBookingResourceIds(roomId); + if (resourceIds.length > 0) { + return serverResolveResourceApproverEmails(resourceIds, tenant); + } + const fallback = await serverGetFinalApproverEmail(tenant); + return fallback ? [fallback] : []; +}; + +const logEmailSendFailure = (context: string, error: unknown) => { + console.error(`[${context}] Failed to send email:`, error); +}; + +const sendEmailFanout = async ( + context: string, + senders: Array<() => Promise>, +): Promise>> => { + const results = await Promise.allSettled(senders.map((send) => send())); + results.forEach((result) => { + if (result.status === "rejected") { + logEmailSendFailure(context, result.reason); + } else if (!result.value.ok) { + logEmailSendFailure( + context, + `Email API returned ${result.value.status} ${result.value.statusText}`, + ); + } + }); + return results; +}; + +const wasAnyEmailSent = ( + results: Array>, +): boolean => + results.some((result) => result.status === "fulfilled" && result.value.ok); + +const sendEmailInBackground = (context: string, promise: Promise) => { + void promise.catch((error) => logEmailSendFailure(context, error)); +}; + const getBookingHistory = async ( booking: Booking, tenant?: string, @@ -288,6 +338,7 @@ export const serverFirstApproveOnly = async ( const doc = await serverGetDataByCalendarEventId<{ id: string; requestNumber: number; + roomId?: string; }>(TableNames.BOOKING, id, tenant); if (!doc) { @@ -313,36 +364,42 @@ export const serverFirstApproveOnly = async ( ...contents, headerMessage: emailConfig.emailNotifications.reviewedNeedsApproval, }; - const recipient = await serverGetFinalApproverEmail(tenant); - if (!recipient) { - + const recipients = await resolveBookingResourceApproverEmails( + doc.roomId, + tenant, + ); + if (recipients.length === 0) { return; } - const formData = { - templateName: "booking_detail", - contents: emailContents, - targetEmail: recipient, - status: BookingStatusLabel.PRE_APPROVED, - eventTitle: contents.title || "", - requestNumber: contents.requestNumber, - bodyMessage: "", - approverType: ApproverType.FINAL_APPROVER, - replyTo: contents.email, - }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-tenant": tenant || DEFAULT_TENANT, - }, - body: JSON.stringify(formData), - }); + const results = await sendEmailFanout( + "first approval", + recipients.map((recipient) => () => + fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-tenant": tenant || DEFAULT_TENANT, + }, + body: JSON.stringify({ + templateName: "booking_detail", + contents: emailContents, + targetEmail: recipient, + status: BookingStatusLabel.PRE_APPROVED, + eventTitle: contents.title || "", + requestNumber: contents.requestNumber, + bodyMessage: "", + approverType: ApproverType.FINAL_APPROVER, + replyTo: contents.email, + }), + }), + ), + ); console.log( `✅ FIRST APPROVAL COMPLETED [${tenant?.toUpperCase() || "UNKNOWN"}]:`, { calendarEventId: id, - emailSent: res.ok, + emailSent: wasAnyEmailSent(results), status: BookingStatusLabel.PRE_APPROVED, }, ); @@ -442,6 +499,7 @@ const firstApprove = async (id: string, email: string, tenant?: string) => { const doc = await serverGetDataByCalendarEventId<{ id: string; requestNumber: number; + roomId?: string; }>(TableNames.BOOKING, id, tenant); if (!doc) { console.error("Booking document not found for calendar event id:", id); @@ -484,32 +542,38 @@ const firstApprove = async (id: string, email: string, tenant?: string) => { ...contents, headerMessage: emailConfig.emailNotifications.reviewedNeedsApproval, }; - const recipient = await serverGetFinalApproverEmail(tenant); - if (!recipient) { - + const recipients = await resolveBookingResourceApproverEmails( + doc.roomId, + tenant, + ); + if (recipients.length === 0) { return; } - const formData = { - templateName: "booking_detail", - contents: emailContents, - targetEmail: recipient, - status: BookingStatusLabel.PRE_APPROVED, - eventTitle: contents.title || "", - requestNumber: contents.requestNumber, - bodyMessage: "", - approverType: ApproverType.FINAL_APPROVER, - replyTo: contents.email, - schemaName: emailConfig.schemaName, - }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-tenant": tenant || DEFAULT_TENANT, - }, - body: JSON.stringify(formData), - }); + await sendEmailFanout( + "booking modification first approval", + recipients.map((recipient) => () => + fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-tenant": tenant || DEFAULT_TENANT, + }, + body: JSON.stringify({ + templateName: "booking_detail", + contents: emailContents, + targetEmail: recipient, + status: BookingStatusLabel.PRE_APPROVED, + eventTitle: contents.title || "", + requestNumber: contents.requestNumber, + bodyMessage: "", + approverType: ApproverType.FINAL_APPROVER, + replyTo: contents.email, + schemaName: emailConfig.schemaName, + }), + }), + ), + ); }; export const finalApprove = async ( @@ -556,6 +620,7 @@ interface SendConfirmationEmailOptions { headerMessage: string; guestEmail: string; tenant?: string; + roomId?: unknown; } export const serverSendBookingDetailEmail = async ({ @@ -586,7 +651,7 @@ export const serverSendBookingDetailEmail = async ({ tenant, schemaName: emailConfig.schemaName, }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { method: "POST", headers: { "Content-Type": "application/json", @@ -602,20 +667,25 @@ export const serverSendConfirmationEmail = async ({ headerMessage, guestEmail, tenant, + roomId, }: SendConfirmationEmailOptions) => { - const email = await serverGetFinalApproverEmail(tenant); - if (!email) { - + const emails = await resolveBookingResourceApproverEmails(roomId, tenant); + if (emails.length === 0) { return; } - serverSendBookingDetailEmail({ - calendarEventId, - targetEmail: email, - headerMessage, - status, - replyTo: guestEmail, - tenant, - }); + await sendEmailFanout( + "approval confirmation", + emails.map((email) => () => + serverSendBookingDetailEmail({ + calendarEventId, + targetEmail: email, + headerMessage, + status, + replyTo: guestEmail, + tenant, + }), + ), + ); }; // server @@ -632,6 +702,8 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { // @ts-ignore const guestEmail = doc.email; + // @ts-ignore + const bookingRoomId = doc.roomId; // Get tenant email configuration for approval notice const emailConfig = await getTenantEmailConfig(tenant); @@ -641,34 +713,44 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { const otherHeaderMessage = `This is a confirmation email.

${emailConfig.emailNotifications.approvedUser}`; // for client - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: guestEmail, - headerMessage: userHeaderMessage, - status: BookingStatusLabel.APPROVED, - tenant, - }); + sendEmailInBackground( + "approval requester notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: guestEmail, + headerMessage: userHeaderMessage, + status: BookingStatusLabel.APPROVED, + tenant, + }), + ); // for second approver - serverSendConfirmationEmail({ - calendarEventId: id, - status: BookingStatusLabel.APPROVED, - headerMessage: otherHeaderMessage, - guestEmail, - tenant, - }); + sendEmailInBackground( + "approval resource approver confirmation", + serverSendConfirmationEmail({ + calendarEventId: id, + status: BookingStatusLabel.APPROVED, + headerMessage: otherHeaderMessage, + guestEmail, + tenant, + roomId: bookingRoomId, + }), + ); // for Samantha const approvedCcEmail = await getApprovalCcEmail(process.env.NEXT_PUBLIC_BRANCH_NAME, tenant); if (approvedCcEmail) { - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: approvedCcEmail, - headerMessage: otherHeaderMessage, - status: BookingStatusLabel.APPROVED, - replyTo: guestEmail, - tenant, - }); + sendEmailInBackground( + "approval cc notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: approvedCcEmail, + headerMessage: otherHeaderMessage, + status: BookingStatusLabel.APPROVED, + replyTo: guestEmail, + tenant, + }), + ); } // for sponsor, if we have one @@ -679,15 +761,18 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { ? contents.sponsorEmail : `${contents.sponsorEmail}@nyu.edu`; - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: sponsorEmailAddress, - headerMessage: - `A reservation that you are the Sponsor of has been approved.

${emailConfig.emailNotifications.approvedUser}`, - status: BookingStatusLabel.APPROVED, - replyTo: guestEmail, - tenant, - }); + sendEmailInBackground( + "approval sponsor notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: sponsorEmailAddress, + headerMessage: + `A reservation that you are the Sponsor of has been approved.

${emailConfig.emailNotifications.approvedUser}`, + status: BookingStatusLabel.APPROVED, + replyTo: guestEmail, + tenant, + }), + ); } // for secondary contact, if we have one @@ -850,7 +935,7 @@ export const firstApproverEmails = async (department: string) => { }; export const serverGetRoomCalendarIds = async ( - roomId: number, + roomId: string | number, tenant?: string, ): Promise => { try { @@ -871,7 +956,8 @@ export const serverGetRoomCalendarIds = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => resource.roomId === roomId, + (resource: any) => + String(resource.resourceId ?? resource.roomId) === String(roomId), ); console.log(`Rooms: ${JSON.stringify(rooms)}`); @@ -889,7 +975,7 @@ export const serverGetRoomCalendarIds = async ( }; export const serverGetRoomCalendarId = async ( - roomId: number, + roomId: string | number, tenant?: string, ): Promise => { try { @@ -910,7 +996,8 @@ export const serverGetRoomCalendarId = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => resource.roomId === roomId, + (resource: any) => + String(resource.resourceId ?? resource.roomId) === String(roomId), ); if (rooms.length > 0) { diff --git a/booking-app/components/src/server/auth.js b/booking-app/components/src/server/auth.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/booking-app/components/src/server/calendars.ts b/booking-app/components/src/server/calendars.ts index d552bc43d..fd7813458 100644 --- a/booking-app/components/src/server/calendars.ts +++ b/booking-app/components/src/server/calendars.ts @@ -30,7 +30,7 @@ export const patchCalendarEvent = async ( export const inviteUserToCalendarEvent = async ( calendarEventId: string, guestEmail: string, - roomId: number, + roomId: string, tenant?: string, ) => { const roomCalendarIds = await serverGetRoomCalendarIds(roomId, tenant); @@ -326,9 +326,7 @@ export const updateCalendarEvent = async ( } const roomCalendarIds = await serverGetRoomCalendarIds( - typeof bookingContents.roomId === "string" - ? parseInt(bookingContents.roomId, 10) - : bookingContents.roomId, + String(bookingContents.roomId).split(",")[0].trim(), tenant, ); console.log(`Room Calendar Ids: ${roomCalendarIds}`); diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index f94e3a59d..954573556 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -125,36 +125,6 @@ export const fetchAllBookings = async ( ); }; -export const getOldSafetyTrainingEmails = () => - // TODO: implement this - []; -// const activeSpreadSheet = SpreadsheetApp.openById( -// OLD_SAFETY_TRAINING_SHEET_ID -// ); -// const activeSheet = activeSpreadSheet.getSheetByName( -// OLD_SAFETY_TRAINING_SHEET_NAME -// ); -// var lastRow = activeSheet.getLastRow(); - -// // get all row3(email) data -// var range = activeSheet.getRange(1, 5, lastRow); -// var values = range.getValues(); - -// const secondSpreadSheet = SpreadsheetApp.openById( -// SECOND_OLD_SAFETY_TRAINING_SHEET_ID -// ); -// const secondSheet = secondSpreadSheet -// .getSheets() -// .find( -// (sheet) => sheet.getSheetId() === SECOND_OLD_SAFETY_TRAINING_SHEET_GID -// ); -// const secondLastRow = secondSheet.getLastRow(); -// const secondRange = secondSheet.getRange(1, 2, secondLastRow); -// const secondValues = secondRange.getValues(); - -// const combinedValues = [...values, ...secondValues]; -// return combinedValues; - export const decline = async ( id: string, email: string, @@ -813,15 +783,17 @@ export const updateOperationHours = async ( open: number, close: number, isClosed: boolean, - roomId?: number, + roomId?: string | number, ) => { const docs = await clientFetchAllDataFromCollection< OperationHours & { id: string } >(TableNames.OPERATION_HOURS); + const normalizedRoomId = + roomId === undefined || roomId === null ? undefined : String(roomId); const match = docs.find((x) => { - if (roomId) { - return x.day === day && x.roomId === roomId; + if (normalizedRoomId) { + return x.day === day && String(x.roomId) === normalizedRoomId; } return x.day === day; }); @@ -835,7 +807,7 @@ export const updateOperationHours = async ( isClosed, }); } else { - const r = roomId ? { roomId } : {}; + const r = normalizedRoomId ? { roomId: normalizedRoomId } : {}; clientSaveDataToFirestore(TableNames.OPERATION_HOURS, { day: day.toString(), open, diff --git a/booking-app/components/src/test/fakeBookingData.ts b/booking-app/components/src/test/fakeBookingData.ts deleted file mode 100644 index 197ca64a4..000000000 --- a/booking-app/components/src/test/fakeBookingData.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Booking, BookingStatus } from "../types"; - -function genFakeBookingRow( - calendarEventId: string, - email: string, - fakeData?: any -): Booking { - const today = new Date(); - today.setMinutes(0); // show a nice time - today.setSeconds(0); - today.setMilliseconds(0); - const endTime = new Date(); - endTime.setHours(today.getHours() + 4); - endTime.setMinutes(0); - endTime.setSeconds(0); - endTime.setMilliseconds(0); - - return { - calendarEventId, - roomId: "224", - email, - startDate: today, - endDate: endTime, - firstName: "Grace", - lastName: "Hopper", - secondaryName: "", - nNumber: "N12345678", - netId: "gh123", - phoneNumber: "555-123-4567", - department: "IDM", - role: "Student", - sponsorFirstName: "Noah", - sponsorLastName: "Pivnick", - sponsorEmail: "nnp278@nyu.edu", - title: "[Test] My Event", - description: "This is a fake booking for testing", - bookingType: "Workshop", - expectedAttendance: "1", - attendeeAffiliation: "NYU Members", - roomSetup: "no", - setupDetails: "", - mediaServices: "", - mediaServicesDetails: "", - equipmentServices: "", - equipmentServicesDetails: "", - staffingServices: "", - staffingServicesDetails: "", - catering: "no", - cateringService: "", - cleaningService: "no", - hireSecurity: "no", - chartFieldForCatering: "", - chartFieldForCleaning: "", - chartFieldForSecurity: "", - chartFieldForRoomSetup: "", - devBranch: "development", - ...fakeData, - }; -} - -function genFakeBookingStatusRow( - calendarEventId: string, - email: string, - fakeData?: any -): BookingStatus { - return { - calendarEventId, - email, - requestedAt: new Date(), - firstApprovedAt: "", - finalApprovedAt: "", - declinedAt: "", - canceledAt: "", - checkedInAt: "", - checkedOutAt: "", - noShowedAt: "", - ...fakeData, - }; -} - -function generateUUID() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - var r = (Math.random() * 16) | 0, - v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -export function addFakeBookingData( - n: number, - fakeData: any, - fakeBookingStatusData: any -) { - let calendarEventId; - const email = "booking-app-devs@itp.nyu.edu"; - const bookingRows = []; - const bookingStatusRows = []; - - for (let i = 0; i < n; i++) { - calendarEventId = generateUUID(); - bookingRows.push(genFakeBookingRow(calendarEventId, email, fakeData)); - } - - return { bookingRows }; -} diff --git a/booking-app/components/src/test/index.js b/booking-app/components/src/test/index.js deleted file mode 100644 index 50010a0ed..000000000 --- a/booking-app/components/src/test/index.js +++ /dev/null @@ -1,33 +0,0 @@ -const express = require('express'); -const bodyParser = require('body-parser'); -const http = require('http'); -const WebSocket = require('ws'); - -const app = express(); -app.use(bodyParser.json()); -const server = http.createServer(app); -const wss = new WebSocket.Server({ server }); -const port = 3001; - -let clients = []; - -wss.on('connection', function connection(ws) { - console.log('Client connected'); - clients.push(ws); - - ws.on('close', () => { - console.log('Client disconnected'); - clients = clients.filter((client) => client !== ws); - }); -}); - -app.post('/bookings', (req, res) => { - console.log(req.body); - console.log('Sending test data to client...'); - clients.forEach((client) => client.send('ADD:' + JSON.stringify(req.body))); - res.send({ status: 200, message: 'Success' }); -}); - -server.listen(port, () => { - console.log('Server started on port ' + port); -}); diff --git a/booking-app/components/src/test/package-lock.json b/booking-app/components/src/test/package-lock.json deleted file mode 100644 index c4093c1b5..000000000 --- a/booking-app/components/src/test/package-lock.json +++ /dev/null @@ -1,1317 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "test", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "body-parser": "^1.20.2", - "ws": "^8.17.0" - }, - "devDependencies": { - "express": "^4.19.2", - "nodemon": "^3.1.3" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nodemon": { - "version": "3.1.13", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.13.tgz", - "integrity": "sha512-nPN6L7A9cTA3BnJ3zZIibH5FiDh3GbmibeS17bl5YEU1IRO2mcfvR0ZJXH3ndoeKItjUcaX81FSKc/Kq/IiG6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dev": true, - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/booking-app/components/src/test/package.json b/booking-app/components/src/test/package.json deleted file mode 100644 index 4049198e3..000000000 --- a/booking-app/components/src/test/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "start": "nodemon index.js" - }, - "author": "", - "license": "ISC", - "devDependencies": { - "express": "^4.19.2", - "nodemon": "^3.1.3" - }, - "dependencies": { - "body-parser": "^1.20.2", - "ws": "^8.17.0" - } -} diff --git a/booking-app/components/src/test/testData.ts b/booking-app/components/src/test/testData.ts deleted file mode 100644 index f9f8bc33d..000000000 --- a/booking-app/components/src/test/testData.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Options } from "yargs"; -import { addFakeBookingData } from "./fakeBookingData"; -import axios from "axios"; -import { hideBin } from "yargs/helpers"; -//@ts-ignore -import yargs from "yargs/yargs"; - -const SERVER_URL = "http://localhost:3001/bookings"; - -const args = process.argv.slice(2); -const n = parseInt(args[0], 10); - -if (isNaN(n)) { - console.error("Specify how many fake bookings to generate"); - process.exit(1); -} - -let fakeData = {}; -let fakeBookingStatusData = {}; - -const options: Record = { - calendarEventId: { type: "string" }, - roomId: { type: "string" }, - email: { type: "string" }, - startDate: { type: "string" }, - endDate: { type: "string" }, - firstName: { type: "string" }, - lastName: { type: "string" }, - secondaryName: { type: "string" }, - nNumber: { type: "string" }, - netId: { type: "string" }, - phoneNumber: { type: "string" }, - department: { type: "string" }, - role: { type: "string" }, - sponsorFirstName: { type: "string" }, - sponsorLastName: { type: "string" }, - sponsorEmail: { type: "string" }, - title: { type: "string" }, - description: { type: "string" }, - bookingType: { type: "string" }, - expectedAttendance: { type: "string" }, - attendeeAffiliation: { type: "string" }, - roomSetup: { type: "string" }, - setupDetails: { type: "string" }, - mediaServices: { type: "string" }, - mediaServicesDetails: { type: "string" }, - equipmentServices: { type: "string" }, - equipmentServicesDetails: { type: "string" }, - staffingServices: { type: "string" }, - staffingServicesDetails: { type: "string" }, - catering: { type: "string" }, - cateringService: { type: "string" }, - cleaningService: { type: "string" }, - hireSecurity: { type: "string" }, - chartFieldForCatering: { type: "string" }, - chartFieldForCleaning: { type: "string" }, - chartFieldForSecurity: { type: "string" }, - chartFieldForRoomSetup: { type: "string" }, - devBranch: { type: "string" }, - status: { type: "string" }, -}; - -const argv = yargs(hideBin(process.argv)) - .options(options) - .parserConfiguration({ "camel-case-expansion": false }).argv; - -const handleStatus = (status: string) => { - const date = new Date(); - switch (status) { - case "finalApproved": - fakeBookingStatusData["finalApprovedAt"] = date; - case "firstApproved": - fakeBookingStatusData["firstApprovedAt"] = date; - return; - case "declined": - fakeBookingStatusData["declinedAt"] = date; - return; - case "canceled": - fakeBookingStatusData["canceledAt"] = date; - return; - case "checkedIn": - fakeBookingStatusData["checkedInAt"] = date; - return; - case "noShow": - fakeBookingStatusData["noShowedAt"] = date; - } -}; - -Object.keys(argv).forEach((key) => { - if (key === "status") { - handleStatus(argv[key] as string); - } - if ( - key != "status" && - key !== "_" && - key !== "$0" && - argv[key] !== undefined - ) { - fakeData[key] = argv[key]; - } -}); - -const data = addFakeBookingData(n, fakeData, fakeBookingStatusData); -axios - .post(SERVER_URL, data) - .then((response) => console.log(response.data)) - .catch((error) => console.error(error)); - -// Usage: -// cd src/test -// start test server: npm run start -// npx tsx testData [number] [any args] diff --git a/booking-app/components/src/testHelpers/testTenantSchemas.ts b/booking-app/components/src/testHelpers/testTenantSchemas.ts index 5d67c6e97..b735acd8e 100644 --- a/booking-app/components/src/testHelpers/testTenantSchemas.ts +++ b/booking-app/components/src/testHelpers/testTenantSchemas.ts @@ -81,7 +81,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 30, name: "Lecture Hall 202", - roomId: 202, + resourceId: "202", isEquipment: false, calendarId: "mock-calendar-202", training: { required: false, formId: "", infoUrl: "" }, @@ -114,7 +114,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 20, name: "Studio 220", - roomId: 220, + resourceId: "220", isEquipment: false, calendarId: "mock-calendar-220", training: { required: false, formId: "", infoUrl: "" }, @@ -147,7 +147,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 15, name: "Training Room 230", - roomId: 230, + resourceId: "230", isEquipment: false, calendarId: "mock-calendar-230", training: { required: true, formId: "https://docs.google.com/forms/d/e/mock/viewform", infoUrl: "" }, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index e5a9e0e1b..ea94bbe74 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -251,7 +251,7 @@ export type OperationHours = { open: number; close: number; isClosed: boolean; - roomId?: number; + roomId?: string; }; export type PaUser = { @@ -286,7 +286,7 @@ export type BlackoutPeriod = { isActive: boolean; createdAt: Timestamp; updatedAt?: Timestamp; - roomIds?: number[]; // Optional array of room IDs - if empty/undefined, applies to all rooms + roomIds?: string[]; // Optional resource IDs; empty/undefined applies to all resources }; export type PolicySettings = { @@ -310,7 +310,7 @@ export enum Role { } export type RoomSetting = { - roomId: number; + roomId: string; name: string; capacity: string; calendarId: string; diff --git a/booking-app/components/src/utils/blackoutUtils.ts b/booking-app/components/src/utils/blackoutUtils.ts index dd99a3771..9832fc0ee 100644 --- a/booking-app/components/src/utils/blackoutUtils.ts +++ b/booking-app/components/src/utils/blackoutUtils.ts @@ -133,7 +133,7 @@ export function isBookingInBlackoutPeriod( period: BlackoutPeriod, bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: number[], + selectedRoomIds: string[], ): boolean { const startDate = dayjs(period.startDate.toDate()); const endDate = dayjs(period.endDate.toDate()); @@ -209,7 +209,7 @@ export function getAffectingBlackoutPeriods( blackoutPeriods: BlackoutPeriod[], bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: number[], + selectedRoomIds: string[], ): BlackoutPeriod[] { const activeBlackoutPeriods = blackoutPeriods.filter( (period) => period.isActive, diff --git a/booking-app/docker-compose.yml b/booking-app/docker-compose.yml deleted file mode 100644 index 9176b7686..000000000 --- a/booking-app/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -# docker-compose.yml -version: "3.8" - -services: - db: - image: mysql:8.0 - restart: always - environment: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_DATABASE: mydatabase - MYSQL_USER: myuser - MYSQL_PASSWORD: mypassword - ports: - - "13306:3306" - volumes: - - db-data:/var/lib/mysql - - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - -volumes: - db-data: diff --git a/booking-app/docs/SCHEMA_SYNC_GUIDE.md b/booking-app/docs/SCHEMA_SYNC_GUIDE.md index 690302b0e..edd66bf82 100644 --- a/booking-app/docs/SCHEMA_SYNC_GUIDE.md +++ b/booking-app/docs/SCHEMA_SYNC_GUIDE.md @@ -56,6 +56,23 @@ resources: defineObjectArrayWithDefaults(defaultResource), This allows the merge process to automatically apply defaults to each item in the array. +### Resource identifiers (`resourceId`) + +The template defines each resource with a string **`resourceId`**. Legacy Firestore documents may still store numeric **`roomId`** values from before the refactor tracked in [#1506](https://github.com/ITPNYU/booking-app/issues/1506). + +When the sync script reads or merges a tenant schema, it coerces legacy `roomId` values into `resourceId` strings via `coerceTenantSchema`. Writing the merged `resources` array back replaces the stored array, so synced tenants pick up `resourceId` from the template. + +For a dedicated one-time migration (including backup and full document replace), use: + +```bash +npm run migrate:tenant-resource-ids:dry-run +npm run migrate:tenant-resource-ids +``` + ## Related: one-time nested shape migration To **rewrite** stored documents from the legacy flat field layout to the nested canonical shape (and drop stale top-level keys), use the Firestore migration script described in [TENANT_SCHEMA_FIRESTORE_MIGRATION.md](./TENANT_SCHEMA_FIRESTORE_MIGRATION.md). That is separate from this add-only sync. + +## See also + +- [Tenant schema CLI backup](./TENANT_SCHEMA_BACKUP.md) — back up `tenantSchema` to `tenantSchemaBackup`; use `--dry-run` for local JSON only. diff --git a/booking-app/docs/TENANT_SCHEMA_BACKUP.md b/booking-app/docs/TENANT_SCHEMA_BACKUP.md new file mode 100644 index 000000000..37b9aea53 --- /dev/null +++ b/booking-app/docs/TENANT_SCHEMA_BACKUP.md @@ -0,0 +1,64 @@ +# Tenant schema backup (CLI) + +Backs up Firestore **`tenantSchema`** documents into the **`tenantSchemaBackup`** collection—the same place **`syncTenantSchemas`** and **`copyCollection`** write before mutating schemas. + +## Behavior + +| Mode | What happens | +|------|----------------| +| **Default** (`npm run backup:tenant-schema`) | Writes one new document per tenant into **`tenantSchemaBackup`**, using `backupTenantSchemaDocument` / `backupTenantSchemaCollection` from `scripts/tenantSchemaBackup.js`. Backup type label in doc ids: **`cli-backup`**. | +| **`--dry-run`** | Does **not** write to Firestore. Exports JSON under **`scripts/output/tenant-schema-backup//`** (same naming pattern as Firestore backup ids) plus **`manifest.json`** for inspection. | + +## Prerequisites + +- Run commands from the `booking-app` directory (where `package.json` lives). +- `.env.local` must define Firebase Admin credentials (`FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_PRIVATE_KEY`), same as `npm run sync:schemas`. + +## Usage + +```bash +# Back up all tenantSchema docs → tenantSchemaBackup (development / default DB) +npm run backup:tenant-schema + +# Dry run: write JSON locally only (no Firestore writes) +npm run backup:tenant-schema -- --dry-run + +# Staging or production Firestore database +npm run backup:tenant-schema -- --database staging +npm run backup:tenant-schema -- --database production + +# Single tenant (one backup document in tenantSchemaBackup) +npm run backup:tenant-schema -- --tenant mc + +# With --dry-run only: optional output directory (default: scripts/output/tenant-schema-backup/) +npm run backup:tenant-schema -- --dry-run --output-dir ./my-preview +``` + +`--output-dir` applies only when **`--dry-run`** is set; it is ignored for real Firestore backups. + +## Firestore backup ids + +Real backups use the same id format as other tooling: +`-backup-cli-backup-` +(see `createBackupDocId` in `scripts/tenantSchemaBackup.js`). + +## Local dry-run output + +- Folder: `scripts/output/tenant-schema-backup//` +- Files: `.json` (serialized Firestore types use `__firestore*` markers for readability) +- `manifest.json` lists tenants and files + +`scripts/output/` is gitignored. + +## Related + +- [Tenant schema sync guide](./SCHEMA_SYNC_GUIDE.md) +- `scripts/tenantSchemaBackup.js` +- `scripts/syncTenantSchemas.ts` — uses backup type `sync-defaults` +- `scripts/copyCollection.js` — uses backup type `copy` + +## Help + +```bash +npm run backup:tenant-schema -- --help +``` diff --git a/booking-app/firebase-debug.log b/booking-app/firebase-debug.log deleted file mode 100644 index da26ac234..000000000 --- a/booking-app/firebase-debug.log +++ /dev/null @@ -1,1623 +0,0 @@ -[debug] [2024-07-08T21:18:52.854Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:18:52.855Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:18:52.855Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:18:52.856Z] Platform: darwin -[debug] [2024-07-08T21:18:52.856Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:18:52.860Z] Time: Mon Jul 08 2024 17:18:52 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:18:52.860Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:18:52.890Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:18:52.890Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:18:52.890Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:18:53.441Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:18:53.442Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:18:53.444Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:18:54.139Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:18:54.140Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:18:55.002Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:18:56.625Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:18:57.260Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:18:57.261Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/BookingActions.tsx:53:35 -Type error: Argument of type 'undefined' is not assignable to parameter of type 'BookingStatusLabel'. - - 51 | .catch(() => { - 52 | onError(); -> 53 | setOptimisticStatus(undefined); - | ^ - 54 | }) - 55 | .finally(() => reload().then(() => setOptimisticStatus(undefined))); - 56 | } catch (ex) { - -[debug] [2024-07-08T21:19:11.487Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:20:14.178Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:20:14.179Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:20:14.180Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:20:14.180Z] Platform: darwin -[debug] [2024-07-08T21:20:14.180Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:20:14.180Z] Time: Mon Jul 08 2024 17:20:14 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:20:14.180Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:20:14.210Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:20:14.210Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:20:14.210Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:20:14.412Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:20:14.412Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:20:14.414Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:20:14.781Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:20:14.782Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:20:15.563Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:20:17.180Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:20:17.551Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:20:17.551Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/BookingActions.tsx:89:19 -Type error: Type 'Promise<{ result: string; }>' is not assignable to type 'Promise'. - - 87 | {ActionButton( - 88 | "Cancel", -> 89 | () => serverFunctions.cancel(calendarEventId), - | ^ - 90 | BookingStatusLabel.CANCELED, - 91 | true - 92 | )} - -[debug] [2024-07-08T21:20:27.663Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:22:26.986Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:22:26.987Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:22:26.987Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:22:26.987Z] Platform: darwin -[debug] [2024-07-08T21:22:26.988Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:22:26.992Z] Time: Mon Jul 08 2024 17:22:26 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:22:26.992Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:22:27.021Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:22:27.021Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:22:27.021Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:22:27.249Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:22:27.250Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:22:27.251Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:22:27.533Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:22:27.533Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:22:28.393Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:22:30.060Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:22:30.219Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:22:30.219Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/Liaisons.tsx:11:27 -Type error: Binding element 'liaisonEmails' implicitly has an 'any' type. - - 9 | import { serverFunctions } from '../../../utils/serverFunctions'; - 10 | -> 11 | const AddLiaisonForm = ({ liaisonEmails, reloadLiaisonEmails }) => { - | ^ - 12 | const [email, setEmail] = useState(''); - 13 | const [department, setDepartment] = useState(''); - 14 | const [loading, setLoading] = useState(false); - -[debug] [2024-07-08T21:22:39.586Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:23:17.645Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:23:17.646Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:23:17.646Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:23:17.646Z] Platform: darwin -[debug] [2024-07-08T21:23:17.646Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:23:17.650Z] Time: Mon Jul 08 2024 17:23:17 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:23:17.650Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:23:17.681Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:23:17.681Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:23:17.682Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:23:17.682Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:23:17.682Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:23:17.683Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:23:17.904Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:23:17.904Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:23:17.905Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:23:18.177Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:23:18.177Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:23:18.994Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:23:20.594Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:23:20.896Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:23:20.897Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/bookingProvider.tsx:70:30 -Type error: Property 'map' does not exist on type '{ result: string; }'. - - 68 | isTrained = await serverFunctions - 69 | .getOldSafetyTrainingEmails() -> 70 | .then((rows) => rows.map((row) => row[0]).includes(userEmail)); - | ^ - 71 | } - 72 | console.log("isTrained from googlesheets", isTrained); - 73 | setIsSafetyTrained(isTrained); - -[debug] [2024-07-08T21:23:31.091Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:24:43.186Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:24:43.187Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:24:43.187Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:24:43.187Z] Platform: darwin -[debug] [2024-07-08T21:24:43.187Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:24:43.192Z] Time: Mon Jul 08 2024 17:24:43 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:24:43.193Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:24:43.230Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:24:43.231Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:24:43.231Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:24:43.454Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:24:43.455Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:24:43.456Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:24:43.973Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:24:43.974Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:24:44.818Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:24:46.967Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:24:47.267Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:24:47.268Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/bookingProvider.tsx:41:35 -Type error: Binding element 'children' implicitly has an 'any' type. - - 39 | }); - 40 | -> 41 | export function BookingProvider({ children }) { - | ^ - 42 | const { bannedUsers, safetyTrainedUsers, userEmail } = - 43 | useContext(DatabaseContext); - 44 | - -[debug] [2024-07-08T21:24:57.354Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:27:55.548Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:27:55.549Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:27:55.549Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:27:55.550Z] Platform: darwin -[debug] [2024-07-08T21:27:55.550Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:27:55.553Z] Time: Mon Jul 08 2024 17:27:55 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:27:55.553Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:27:55.583Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:27:55.583Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:27:55.583Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:27:56.204Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:27:56.205Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:27:56.206Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:27:56.882Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:27:56.883Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:27:57.732Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:27:59.356Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:27:59.998Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:27:59.999Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/components/CalendarDatePicker.tsx:4:38 -Type error: Binding element 'handleChange' implicitly has an 'any' type. - - 2 | import Datepicker from 'tailwind-datepicker-react'; - 3 | -> 4 | export const CalendarDatePicker = ({ handleChange }) => { - | ^ - 5 | const options = { - 6 | title: 'Date', - 7 | autoHide: true, - -[debug] [2024-07-08T21:28:09.675Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:28:29.556Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:28:29.557Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:28:29.557Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:28:29.557Z] Platform: darwin -[debug] [2024-07-08T21:28:29.557Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:28:29.560Z] Time: Mon Jul 08 2024 17:28:29 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:28:29.560Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:28:29.591Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:28:29.591Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:28:29.591Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:28:29.796Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:28:29.796Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:28:29.797Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:28:30.023Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:28:30.024Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:28:30.840Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:28:32.470Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:28:32.774Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:28:32.774Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/components/Calendars.tsx:26:22 -Type error: Parameter 'info' implicitly has an 'any' type. - - 24 | const [bookingTimeEvent, setBookingTimeEvent] = useState(); - 25 | -> 26 | const isOverlap = (info) => { - | ^ - 27 | return selectedRooms.some((room, i) => { - 28 | const calendarApi = room.calendarRef.current.getApi(); - 29 | - -[debug] [2024-07-08T21:28:42.660Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:43:26.080Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:43:26.081Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:43:26.081Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:43:26.081Z] Platform: darwin -[debug] [2024-07-08T21:43:26.081Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:43:26.085Z] Time: Mon Jul 08 2024 17:43:26 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:43:26.085Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:43:26.114Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:43:26.114Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:43:26.114Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:43:26.715Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:43:26.716Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:43:26.718Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:43:27.428Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:43:27.429Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:43:28.294Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:43:30.007Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:43:30.757Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:43:30.758Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] Disabled SWC as replacement for Babel because of custom Babel configuration ".babelrc" https://nextjs.org/docs/messages/swc-disabled - -[info] Using external babel configuration from /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.babelrc - -[info] ⚠ It looks like there is a custom Babel configuration that can be removed. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/aiplatform/v1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/aiplatform/v1beta1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/v1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/alpha.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/beta.js as it exceeds the max of 500KB. - -[info] Failed to compile. - - -[info] ./app/layout.tsx:3:1 -Syntax error: "next/font" requires SWC although Babel is being used due to a custom babel config being present. -Read more: https://nextjs.org/docs/messages/babel-font-loader-conflict - - -[info] - -[info] > Build failed because of webpack errors - -[debug] [2024-07-08T21:43:59.930Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/server/middleware-manifest.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:48:05.360Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:48:05.361Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:48:05.361Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:48:05.361Z] Platform: darwin -[debug] [2024-07-08T21:48:05.361Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:48:05.364Z] Time: Mon Jul 08 2024 17:48:05 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:48:05.364Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:48:05.396Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:48:05.397Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:48:05.397Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:48:05.607Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:48:05.607Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:48:05.609Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:48:05.958Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:48:05.959Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:48:06.782Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:48:08.449Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:48:08.752Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:48:08.753Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] Failed to compile. - -./components/src/client/routes/booking/components/Calendars.tsx:26:22 -Type error: Parameter 'info' implicitly has an 'any' type. - - 24 | const [bookingTimeEvent, setBookingTimeEvent] = useState(); - 25 | -> 26 | const isOverlap = (info) => { - | ^ - 27 | return selectedRooms.some((room, i) => { - 28 | const calendarApi = room.calendarRef.current.getApi(); - 29 | - -[debug] [2024-07-08T21:48:28.421Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T14:59:05.210Z] ---------------------------------------------------------------------- -[debug] [2024-07-11T14:59:05.210Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase emulators:start -[debug] [2024-07-11T14:59:05.211Z] CLI Version: 13.13.0 -[debug] [2024-07-11T14:59:05.211Z] Platform: darwin -[debug] [2024-07-11T14:59:05.211Z] Node Version: v18.17.0 -[debug] [2024-07-11T14:59:05.211Z] Time: Thu Jul 11 2024 10:59:05 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-11T14:59:05.211Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-11T14:59:05.213Z] >>> [apiv2][query] GET https://firebase-public.firebaseio.com/cli.json [none] -[debug] [2024-07-11T14:59:05.263Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-11T14:59:05.263Z] > authorizing via signed-in user (rh3555@nyu.edu) -[info] i emulators: Starting emulators: hosting {"metadata":{"emulator":{"name":"hub"},"message":"Starting emulators: hosting"}} -[debug] [2024-07-11T14:59:05.718Z] [logging] Logging Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[warn] ⚠ hosting: Hosting Emulator unable to start on port 5000, starting on 5002 instead. {"metadata":{"emulator":{"name":"hosting"},"message":"Hosting Emulator unable to start on port 5000, starting on 5002 instead."}} -[debug] [2024-07-11T14:59:05.718Z] [hosting] Hosting Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:05.718Z] assigned listening specs for emulators {"user":{"hub":[{"address":"127.0.0.1","family":"IPv4","port":4400},{"address":"::1","family":"IPv6","port":4400}],"logging":[{"address":"127.0.0.1","family":"IPv4","port":4500}],"hosting":[{"address":"127.0.0.1","family":"IPv4","port":5002}]},"metadata":{"message":"assigned listening specs for emulators"}} -[debug] [2024-07-11T14:59:05.721Z] [hub] writing locator at /var/folders/0t/pc3g628n3ylg5pnvtgy3y8h00000gn/T/hub-flowing-mantis-389917.json -[debug] [2024-07-11T14:59:05.725Z] > refreshing access token with scopes: [] -[debug] [2024-07-11T14:59:05.725Z] >>> [apiv2][query] POST https://www.googleapis.com/oauth2/v3/token [none] -[debug] [2024-07-11T14:59:05.726Z] >>> [apiv2][body] POST https://www.googleapis.com/oauth2/v3/token [omitted] -[debug] [2024-07-11T14:59:05.817Z] <<< [apiv2][status] GET https://firebase-public.firebaseio.com/cli.json 200 -[debug] [2024-07-11T14:59:05.818Z] <<< [apiv2][body] GET https://firebase-public.firebaseio.com/cli.json {"cloudBuildErrorAfter":1594252800000,"cloudBuildWarnAfter":1590019200000,"defaultNode10After":1594252800000,"minVersion":"3.0.5","node8DeploysDisabledAfter":1613390400000,"node8RuntimeDisabledAfter":1615809600000,"node8WarnAfter":1600128000000} -[debug] [2024-07-11T14:59:06.235Z] <<< [apiv2][status] POST https://www.googleapis.com/oauth2/v3/token 200 -[debug] [2024-07-11T14:59:06.235Z] <<< [apiv2][body] POST https://www.googleapis.com/oauth2/v3/token [omitted] -[debug] [2024-07-11T14:59:06.248Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-11T14:59:06.906Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-11T14:59:06.907Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_eef0285d-0deb-4624-91cc-ffd3664399c8"} -[debug] [2024-07-11T14:59:07.796Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[debug] [2024-07-11T14:59:07.798Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites pageToken=&pageSize=10 -[debug] [2024-07-11T14:59:08.549Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites 200 -[debug] [2024-07-11T14:59:08.550Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites {"sites":[{"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"}]} -[debug] [2024-07-11T14:59:08.551Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config [none] -[debug] [2024-07-11T14:59:09.153Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config 200 -[debug] [2024-07-11T14:59:09.153Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config {"projectId":"flowing-mantis-389917","appId":"1:289027705740:web:31820e33df9be4d65d24a9","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central","apiKey":"AIzaSyDdFBW-MHSbBCHg-6TLBgJUmp1hacx7Vr8","authDomain":"flowing-mantis-389917.firebaseapp.com","messagingSenderId":"289027705740","measurementId":"G-B2TH2J5RFR"} -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-11T14:59:21.854Z] [web frameworks] effective firebase.json: { - "hosting": [ - { - "source": ".", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "frameworksBackend": { - "region": "us-central1" - }, - "site": "flowing-mantis-389917", - "rewrites": [ - { - "source": "**", - "function": { - "functionId": "ssrflowingmantis389917", - "region": "us-central1", - "pinTag": true - } - } - ], - "redirects": [], - "headers": [ - { - "source": "**/*.[jt]s", - "headers": [ - { - "key": "Set-Cookie", - "value": "__FIREBASE_DEFAULTS__=eyJjb25maWciOnsicHJvamVjdElkIjoiZmxvd2luZy1tYW50aXMtMzg5OTE3IiwiYXBwSWQiOiIxOjI4OTAyNzcwNTc0MDp3ZWI6MzE4MjBlMzNkZjliZTRkNjVkMjRhOSIsInN0b3JhZ2VCdWNrZXQiOiJmbG93aW5nLW1hbnRpcy0zODk5MTcuYXBwc3BvdC5jb20iLCJsb2NhdGlvbklkIjoidXMtY2VudHJhbCIsImFwaUtleSI6IkFJemFTeURkRkJXLU1IU2JCQ0hnLTZUTEJnSlVtcDFoYWN4N1ZyOCIsImF1dGhEb21haW4iOiJmbG93aW5nLW1hbnRpcy0zODk5MTcuZmlyZWJhc2VhcHAuY29tIiwibWVzc2FnaW5nU2VuZGVySWQiOiIyODkwMjc3MDU3NDAiLCJtZWFzdXJlbWVudElkIjoiRy1CMlRIMko1UkZSIn0sIl9hdXRoVG9rZW5TeW5jVVJMIjoiL19fc2Vzc2lvbiJ9; SameSite=Strict; Expires=2026-06-06T01:39:21.854Z; Path=/;" - } - ] - } - ], - "cleanUrls": true, - "public": ".firebase/flowing-mantis-389917/hosting", - "webFramework": "next_ssr" - } - ], - "functions": [ - { - "source": ".firebase/flowing-mantis-389917/functions", - "codebase": "firebase-frameworks-flowing-mantis-389917" - } - ] -} -[debug] [2024-07-11T14:59:22.130Z] [functions] Functions Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:22.130Z] [eventarc] Eventarc Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:22.130Z] late-assigned ports for functions and eventarc emulators {"user":{"hub":[{"address":"127.0.0.1","family":"IPv4","port":4400},{"address":"::1","family":"IPv6","port":4400}],"logging":[{"address":"127.0.0.1","family":"IPv4","port":4500}],"hosting":[{"address":"127.0.0.1","family":"IPv4","port":5002}],"functions":[{"address":"127.0.0.1","family":"IPv4","port":5001}],"eventarc":[{"address":"127.0.0.1","family":"IPv4","port":9299}]},"metadata":{"message":"late-assigned ports for functions and eventarc emulators"}} -[warn] ⚠ functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, firestore, database, pubsub, storage, dataconnect {"metadata":{"emulator":{"name":"functions"},"message":"The following emulators are not running, calls to these services from the Functions emulator will affect production: \u001b[1mauth, firestore, database, pubsub, storage, dataconnect\u001b[22m"}} -[warn] ⚠ functions: Your GOOGLE_APPLICATION_CREDENTIALS environment variable points to /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json. Non-emulated services will access production using these credentials. Be careful! {"metadata":{"emulator":{"name":"functions"},"message":"Your GOOGLE_APPLICATION_CREDENTIALS environment variable points to /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json. Non-emulated services will access production using these credentials. Be careful!"}} -[debug] [2024-07-11T14:59:22.134Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig [none] -[debug] [2024-07-11T14:59:22.504Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig 200 -[debug] [2024-07-11T14:59:22.505Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig {"projectId":"flowing-mantis-389917","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central"} -[debug] [2024-07-11T14:59:22.530Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites -[debug] [2024-07-11T14:59:22.909Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites 200 -[debug] [2024-07-11T14:59:22.909Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites {"sites":[{"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"}]} -[debug] [2024-07-11T14:59:22.910Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config [none] -[debug] [2024-07-11T14:59:23.412Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config 200 -[debug] [2024-07-11T14:59:23.413Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config {"projectId":"flowing-mantis-389917","appId":"1:289027705740:web:31820e33df9be4d65d24a9","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central","apiKey":"AIzaSyDdFBW-MHSbBCHg-6TLBgJUmp1hacx7Vr8","authDomain":"flowing-mantis-389917.firebaseapp.com","messagingSenderId":"289027705740","measurementId":"G-B2TH2J5RFR"} -[info] i hosting[flowing-mantis-389917]: Serving hosting files from: .firebase/flowing-mantis-389917/hosting {"metadata":{"emulator":{"name":"hosting"},"message":"Serving hosting files from: \u001b[1m.firebase/flowing-mantis-389917/hosting\u001b[22m"}} -[info] ✔ hosting[flowing-mantis-389917]: Local server: http://127.0.0.1:5002 {"metadata":{"emulator":{"name":"hosting"},"message":"Local server: \u001b[4m\u001b[1mhttp://127.0.0.1:5002\u001b[22m\u001b[24m"}} -[info] i functions: Watching "/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions" for Cloud Functions... {"metadata":{"emulator":{"name":"functions"},"message":"Watching \"/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions\" for Cloud Functions..."}} -[debug] [2024-07-11T14:59:23.539Z] Validating nodejs source -[warn] ⚠ functions: package.json indicates an outdated version of firebase-functions. Please upgrade using npm install --save firebase-functions@latest in your functions directory. -[warn] ⚠ functions: Please note that there will be breaking changes when you upgrade. -[debug] [2024-07-11T14:59:23.837Z] > [functions] package.json contents: { - "main": "server.js", - "dependencies": { - "firebase-frameworks": "^0.11.0", - "firebase-functions": "^4.5.0", - "firebase-admin": "^11.11.1" - }, - "engines": { - "node": "18" - } -} -[debug] [2024-07-11T14:59:23.837Z] Building nodejs source -[debug] [2024-07-11T14:59:23.837Z] Failed to find version of module node: reached end of search path /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions/node_modules -[info] ✔ functions: Using node@18 from host. -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PROJECT_ID: Error: Key FIREBASE_PROJECT_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PRIVATE_KEY_ID: Error: Key FIREBASE_PRIVATE_KEY_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PRIVATE_KEY: Error: Key FIREBASE_PRIVATE_KEY starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_EMAIL: Error: Key FIREBASE_CLIENT_EMAIL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_ID: Error: Key FIREBASE_CLIENT_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_AUTH_URI: Error: Key FIREBASE_AUTH_URI starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_TOKEN_URI: Error: Key FIREBASE_TOKEN_URI starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_AUTH_PROVIDER_X509_CERT_URL: Error: Key FIREBASE_AUTH_PROVIDER_X509_CERT_URL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_X509_CERT_URL: Error: Key FIREBASE_CLIENT_X509_CERT_URL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[error] ⬢ functions: Failed to load function definition from source: FirebaseError: Failed to load environment variables from .env.local. {"metadata":{"emulator":{"name":"functions"},"message":"Failed to load function definition from source: FirebaseError: Failed to load environment variables from .env.local."}} -[info] -┌─────────────────────────────────────────────────────────────┐ -│ ✔ All emulators ready! It is now safe to connect your app. │ -└─────────────────────────────────────────────────────────────┘ - -┌───────────┬────────────────┐ -│ Emulator │ Host:Port │ -├───────────┼────────────────┤ -│ Functions │ 127.0.0.1:5001 │ -├───────────┼────────────────┤ -│ Hosting │ 127.0.0.1:5002 │ -└───────────┴────────────────┘ - Emulator Hub running at 127.0.0.1:4400 - Other reserved ports: 4500 - -Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files. - -[debug] [2024-07-11T15:06:00.878Z] Received signal SIGHUP 1 -[info] -[info] i emulators: Received SIGHUP for the first time. Starting a clean shutdown. -[info] i emulators: Please wait for a clean shutdown or send the SIGHUP signal again to stop right now. -[debug] [2024-07-11T15:06:00.888Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger. [as info] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:81:14) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/emulator/commandUtils.js:171:29) - at process.emit (node:events:514:28) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.900Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.903Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.905Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.908Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.912Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.914Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.916Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.917Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.919Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.921Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.922Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.923Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.925Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.925Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.926Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.928Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.928Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.929Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.930Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[info] i emulators: Shutting down emulators. {"metadata":{"emulator":{"name":"hub"},"message":"Shutting down emulators."}} -[info] i functions: Stopping Functions Emulator {"metadata":{"emulator":{"name":"functions"},"message":"Stopping Functions Emulator"}} -[info] i hosting: Stopping Hosting Emulator {"metadata":{"emulator":{"name":"hosting"},"message":"Stopping Hosting Emulator"}} -[info] i eventarc: Stopping Eventarc Emulator {"metadata":{"emulator":{"name":"eventarc"},"message":"Stopping Eventarc Emulator"}} -[info] i hub: Stopping emulator hub {"metadata":{"emulator":{"name":"hub"},"message":"Stopping emulator hub"}} -[debug] [2024-07-11T15:06:00.943Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at Readable.read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:451:26) - at flow (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:858:34) - at File.pipeOnDrainFunctionResult (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:690:7) - at File.emit (node:events:514:28) - at File.emit (node:domain:489:12) - at onwriteDrain (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:453:12) - at afterWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:441:18) - at onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:436:7) - at WritableState.onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:160:5) - at PassThrough. (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/file.js:227:9) - at Object.onceWrapper (node:events:628:28) - at PassThrough.emit (node:events:514:28) - at PassThrough.emit (node:domain:489:12) - at onwriteDrain (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:453:12) - at afterWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:441:18) - at onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:436:7) - at WritableState.onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:160:5) - at PassThrough.afterTransform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:86:3) - at PassThrough._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_passthrough.js:36:3) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Readable.read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:428:10) - at maybeReadMore_ (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:550:12) - at process.processTicksAndRejections (node:internal/process/task_queues:82:21) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.943Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.945Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.945Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.947Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at console.value (node:internal/console/constructor:305:16) - at console.warn (node:internal/console/constructor:385:26) - at prefixedLog (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/build/output/log.js:80:31) - at Object.error (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/build/output/log.js:90:5) - at Object.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.js:751:22) - at process.processTicksAndRejections (node:internal/process/task_queues:95:5) - at async DevBundlerService.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/lib/dev-bundler-service.js:21:20) - at async DevServer.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/dev/next-dev-server.js:393:9) diff --git a/booking-app/firebase.json b/booking-app/firebase.json deleted file mode 100644 index 9dd26e465..000000000 --- a/booking-app/firebase.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "hosting": { - "source": ".", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], - "frameworksBackend": { - "region": "us-central1" - } - } -} diff --git a/booking-app/firestore.indexes.json b/booking-app/firestore.indexes.json index ee02019c6..fddae24d8 100644 --- a/booking-app/firestore.indexes.json +++ b/booking-app/firestore.indexes.json @@ -16,6 +16,22 @@ { "fieldPath": "startDate", "order": "DESCENDING" } ] }, + { + "collectionGroup": "mc-bookings", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "email", "order": "ASCENDING" }, + { "fieldPath": "requestedAt", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "itp-bookings", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "email", "order": "ASCENDING" }, + { "fieldPath": "requestedAt", "order": "ASCENDING" } + ] + }, { "collectionGroup": "mc-bookingLogs", "queryScope": "COLLECTION", diff --git a/booking-app/init-db.sql b/booking-app/init-db.sql deleted file mode 100644 index 0290dd40d..000000000 --- a/booking-app/init-db.sql +++ /dev/null @@ -1,4 +0,0 @@ --- init-db.sql -CREATE DATABASE IF NOT EXISTS mydatabase_shadow; -GRANT ALL PRIVILEGES ON mydatabase_shadow.* TO 'myuser'@'%'; -FLUSH PRIVILEGES; diff --git a/booking-app/lib/api/authz.ts b/booking-app/lib/api/authz.ts index df689e688..224e731be 100644 --- a/booking-app/lib/api/authz.ts +++ b/booking-app/lib/api/authz.ts @@ -90,6 +90,7 @@ const POLICY: Record = { [TableNames.SUPER_ADMINS]: { read: "anyNYU", write: "superOnly" }, [TableNames.USERS_RIGHTS]: { read: "anyNYU", write: "adminOrSuper" }, [TableNames.APPROVERS]: { read: "anyNYU", write: "adminOrSuper" }, + [TableNames.RESOURCE_APPROVERS]: { read: "adminOrSuper", write: "adminOrSuper" }, [TableNames.ADMINS]: { read: "anyNYU", write: "adminOrSuper" }, [TableNames.PAS]: { read: "anyNYU", write: "adminOrSuper" }, diff --git a/booking-app/lib/api/firestoreShared.ts b/booking-app/lib/api/firestoreShared.ts index 166dec1c5..6bb6b6b45 100644 --- a/booking-app/lib/api/firestoreShared.ts +++ b/booking-app/lib/api/firestoreShared.ts @@ -82,6 +82,13 @@ export type MutateRequest = docId: string; data: Record; } + | { + op: "set"; + collection: string; + tenant?: string; + docId: string; + data: Record; + } | { op: "delete"; collection: string; diff --git a/booking-app/lib/bookingRequestLimits.ts b/booking-app/lib/bookingRequestLimits.ts new file mode 100644 index 000000000..4d5f30723 --- /dev/null +++ b/booking-app/lib/bookingRequestLimits.ts @@ -0,0 +1,463 @@ +import type { + RequestLimitBucketKey, + RequestLimitPeriod, + SchemaContextType, +} from "@/components/src/client/routes/components/SchemaProvider"; +import { FormContextLevel, Role } from "@/components/src/types"; +import { TableNames } from "@/components/src/policy"; +import { serverFetchAllDataFromCollection } from "@/lib/firebase/server/adminDb"; +import { formatInTimeZone, toDate } from "date-fns-tz"; +import { Timestamp } from "firebase-admin/firestore"; + +/** IANA zone used for request-limit calendar windows (day / week / month / semester). */ +export const REQUEST_LIMITS_TIME_ZONE = "America/New_York" as const; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Wall-clock calendar parts in {@link REQUEST_LIMITS_TIME_ZONE} for instant `now`. */ +function nyCalendarParts(now: Date) { + const tz = REQUEST_LIMITS_TIME_ZONE; + return { + y: Number(formatInTimeZone(now, tz, "yyyy")), + m: Number(formatInTimeZone(now, tz, "M")) - 1, + d: Number(formatInTimeZone(now, tz, "d")), + /** ISO weekday: 1 = Monday … 7 = Sunday */ + isoDow: Number(formatInTimeZone(now, tz, "i")), + }; +} + +/** Start of that calendar date at 00:00 in {@link REQUEST_LIMITS_TIME_ZONE} (UTC instant). */ +function nyMidnight(y: number, monthIndex0: number, day: number): Date { + return toDate(`${y}-${pad2(monthIndex0 + 1)}-${pad2(day)}T00:00:00.000`, { + timeZone: REQUEST_LIMITS_TIME_ZONE, + }); +} + +export function parseRoleEnumFromLabel( + label: string | undefined, +): Role | undefined { + if (!label) return undefined; + const normalized = label.trim().toLowerCase(); + const entries = Object.values(Role) as string[]; + const match = entries.find((v) => v.toLowerCase() === normalized); + return match as Role | undefined; +} + +/** + * Maps the booking's role (display label → Role enum) to the `requestLimits` map key. + * VIP / walk-in / full form share the same limits bucket (student / faculty / admin). + */ +export function getRequestLimitRoleKey( + _formContext: FormContextLevel, + roleLabel: string | undefined, +): RequestLimitBucketKey { + const roleEnum = parseRoleEnumFromLabel(roleLabel); + switch (roleEnum) { + case Role.FACULTY: + case Role.RESIDENT_FELLOW: + return "faculty"; + case Role.ADMIN_STAFF: + case Role.CHAIR_PROGRAM_DIRECTOR: + return "admin"; + case Role.STUDENT: + default: + return "student"; + } +} + +/** Calendar windows for request limits in {@link REQUEST_LIMITS_TIME_ZONE} (handles DST). */ +export function getNewYorkWindowForPeriod( + now: Date, + period: RequestLimitPeriod, + termConfig?: SchemaContextType["termConfig"], +): { start: Date; end: Date } { + const parts = nyCalendarParts(now); + const { y, m, d, isoDow } = parts; + + if (period === "perDay") { + const start = nyMidnight(y, m, d); + const next = new Date(Date.UTC(y, m, d + 1)); + const end = nyMidnight( + next.getUTCFullYear(), + next.getUTCMonth(), + next.getUTCDate(), + ); + return { start, end }; + } + + if (period === "perWeek") { + const daysSinceMonday = (isoDow + 6) % 7; + const startDate = new Date(Date.UTC(y, m, d - daysSinceMonday)); + const endDate = new Date(Date.UTC(y, m, d - daysSinceMonday + 7)); + const start = nyMidnight( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate(), + ); + const end = nyMidnight( + endDate.getUTCFullYear(), + endDate.getUTCMonth(), + endDate.getUTCDate(), + ); + return { start, end }; + } + + if (period === "perMonth") { + const start = nyMidnight(y, m, 1); + const nextMonth = new Date(Date.UTC(y, m + 1, 1)); + const end = nyMidnight( + nextMonth.getUTCFullYear(), + nextMonth.getUTCMonth(), + nextMonth.getUTCDate(), + ); + return { start, end }; + } + + const month = m + 1; + + const ranges = + termConfig?.fallTerm && termConfig?.springTerm && termConfig?.summerTerm + ? [ + { range: termConfig.springTerm }, + { range: termConfig.summerTerm }, + { range: termConfig.fallTerm }, + ] + : null; + + if (ranges) { + const inRange = (range: [number, number]) => { + const [startMonth, endMonth] = range; + if ( + !Number.isFinite(startMonth) || + !Number.isFinite(endMonth) || + startMonth < 1 || + startMonth > 12 || + endMonth < 1 || + endMonth > 12 || + startMonth > endMonth + ) { + return false; + } + return month >= startMonth && month <= endMonth; + }; + + const active = ranges.find((r) => inRange(r.range)); + if (active) { + const [startMonth, endMonth] = active.range; + const start = nyMidnight(y, startMonth - 1, 1); + const endExclusive = new Date(Date.UTC(y, endMonth, 1)); + const end = nyMidnight( + endExclusive.getUTCFullYear(), + endExclusive.getUTCMonth(), + endExclusive.getUTCDate(), + ); + return { start, end }; + } + } + + const semesterStartMonth0 = Math.floor(m / 4) * 4; + const start = nyMidnight(y, semesterStartMonth0, 1); + const endAnchor = new Date(Date.UTC(y, semesterStartMonth0 + 4, 1)); + const end = nyMidnight( + endAnchor.getUTCFullYear(), + endAnchor.getUTCMonth(), + endAnchor.getUTCDate(), + ); + return { start, end }; +} + +/** @deprecated Use {@link getNewYorkWindowForPeriod} */ +export const getLocalWindowForPeriod = getNewYorkWindowForPeriod; + +function normalizeResourceId(value: unknown): string | null { + if (value == null) return null; + const resourceId = String(value).trim(); + return resourceId || null; +} + +export function parseRoomIdsFromBooking(doc: any): string[] { + if (Array.isArray(doc?.roomIds)) { + return doc.roomIds + .map((x: unknown) => normalizeResourceId(x)) + .filter( + (resourceId: string | null): resourceId is string => resourceId != null, + ); + } + + const raw = doc?.roomId; + if (raw != null) { + return String(raw) + .split(",") + .map((s) => normalizeResourceId(s)) + .filter( + (resourceId: string | null): resourceId is string => resourceId != null, + ); + } + + return []; +} + +function isUnlimitedLimit(limit: unknown): boolean { + return limit == null || limit === -1; +} + +/** + * Reads a configured cap from schema (-1 / missing = unlimited). + * Coerces numeric strings from Firestore (e.g. "0") so limit 0 is enforced. + */ +function parseConfiguredRequestLimit(raw: unknown): number | undefined { + if (isUnlimitedLimit(raw)) return undefined; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0) return undefined; + return n; +} + +function getRequestedAtMillis(doc: any): number | null { + const v = doc?.requestedAt; + if (!v) return null; + if (typeof v?.toMillis === "function") return v.toMillis(); + if (v instanceof Date) return v.getTime(); + if (typeof v === "number") return v; + if (typeof v?.seconds === "number") return v.seconds * 1000; + return null; +} + +function getChangedAtMillis(log: any): number { + const v = log?.changedAt; + const ms = getRequestedAtMillis({ requestedAt: v }); + return ms ?? 0; +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +function normalizeStatusUpper(v: unknown): string | null { + if (typeof v !== "string") return null; + const s = v.trim(); + if (!s) return null; + return s.toUpperCase(); +} + +function isTerminalInactiveStatus(statusUpper: string | null): boolean { + if (!statusUpper) return false; + return statusUpper === "CANCELED" || statusUpper === "DECLINED"; +} + +async function getLatestLogStatusByRequestNumber( + requestNumbers: number[], + tenant?: string, +): Promise> { + const out = new Map(); + if (requestNumbers.length === 0) return out; + + // Firestore `in` supports up to 10 values. + const batches = chunk(requestNumbers, 10); + const logs = ( + await Promise.all( + batches.map((nums) => + serverFetchAllDataFromCollection( + TableNames.BOOKING_LOGS, + [{ field: "requestNumber", operator: "in", value: nums }], + tenant, + ), + ), + ) + ).flat(); + + const latestByReq = new Map(); + for (const log of logs) { + const req = Number(log?.requestNumber); + if (!Number.isFinite(req)) continue; + const statusUpper = normalizeStatusUpper(log?.status); + if (!statusUpper) continue; + const ms = getChangedAtMillis(log); + const prev = latestByReq.get(req); + if (!prev || ms >= prev.ms) { + latestByReq.set(req, { ms, statusUpper }); + } + } + + for (const [req, v] of latestByReq.entries()) { + out.set(req, v.statusUpper); + } + return out; +} + +function isActiveBookingByLastLog({ + booking, + latestLogStatusByRequestNumber, +}: { + booking: any; + latestLogStatusByRequestNumber: Map; +}): boolean { + const req = Number(booking?.requestNumber); + const last = Number.isFinite(req) + ? (latestLogStatusByRequestNumber.get(req) ?? null) + : null; + + // Strict "last-log wins": if we have a last log, use it. + if (last) return !isTerminalInactiveStatus(last); + + // No logs: treat as active (legacy/missing-log bookings should still count). + return true; +} + +export async function enforceRequestLimits({ + tenant, + email, + bookingRoleField, + limitRoleKey, + selectedRoomIds, + schema, +}: { + tenant: string; + email: string; + /** Value stored on booking documents (`data.role`), usually the display label (e.g. "Student"). */ + bookingRoleField: string; + /** Key used in `resource.requestLimits` maps: `student` | `faculty` | `admin` (no origin suffix). */ + limitRoleKey: RequestLimitBucketKey; + selectedRoomIds: string[]; + schema: SchemaContextType | null; +}): Promise<{ ok: true } | { ok: false; message: string }> { + if (!schema?.resources || schema.resources.length === 0) + return { ok: true } as const; + + const resourcesByRoomId = new Map(); + for (const r of schema.resources) { + const resourceId = + normalizeResourceId((r as any)?.resourceId) ?? + normalizeResourceId((r as any)?.roomId); + if (resourceId) resourcesByRoomId.set(resourceId, r); + } + + const now = new Date(); + const periods: RequestLimitPeriod[] = [ + "perDay", + "perWeek", + "perMonth", + "perSemester", + ]; + + const uniqueSelectedRoomIds = Array.from(new Set(selectedRoomIds)).filter( + (roomId) => resourcesByRoomId.has(roomId), + ); + if (uniqueSelectedRoomIds.length === 0) return { ok: true } as const; + + const periodsToQuery = periods.filter((period) => + uniqueSelectedRoomIds.some((roomId) => { + const resource = resourcesByRoomId.get(roomId); + const raw = resource?.requestLimits?.[period]?.[limitRoleKey]; + return parseConfiguredRequestLimit(raw) !== undefined; + }), + ); + if (periodsToQuery.length === 0) return { ok: true } as const; + + const countsByPeriod: Partial< + Record> + > = {}; + + // Pre-compute windows so we can bound the Firestore query by the earliest + // window start. Without this bound, heavy users fetch their full booking + // history on every request, which dominates the per-request Firestore read + // count and contributes to App Engine memory pressure. + // Requires a composite index `(email ASC, requestedAt ASC)` on each + // tenant's bookings collection — see firestore.indexes.json. + const windowsByPeriod = new Map< + RequestLimitPeriod, + { start: Date; end: Date } + >(); + for (const period of periodsToQuery) { + windowsByPeriod.set( + period, + getNewYorkWindowForPeriod(now, period, schema.termConfig), + ); + } + const earliestWindowStart = Array.from(windowsByPeriod.values()).reduce( + (min, w) => (w.start.getTime() < min.getTime() ? w.start : min), + Array.from(windowsByPeriod.values())[0].start, + ); + + const allByEmail = await serverFetchAllDataFromCollection( + TableNames.BOOKING, + [ + { field: "email", operator: "==", value: email }, + { + field: "requestedAt", + operator: ">=", + value: Timestamp.fromDate(earliestWindowStart), + }, + ], + tenant, + ); + + const requestNumbers = Array.from( + new Set( + allByEmail + .map((b: any) => Number(b?.requestNumber)) + .filter((n: number) => Number.isFinite(n)), + ), + ); + const latestLogStatusByRequestNumber = + await getLatestLogStatusByRequestNumber(requestNumbers, tenant); + + const relevant = allByEmail.filter((d: any) => { + if (d?.role !== bookingRoleField) return false; + return isActiveBookingByLastLog({ + booking: d, + latestLogStatusByRequestNumber, + }); + }); + + for (const period of periodsToQuery) { + const { start, end } = windowsByPeriod.get(period)!; + const startMs = start.getTime(); + const endMs = end.getTime(); + + const roomCounts = new Map(); + for (const doc of relevant) { + const ms = getRequestedAtMillis(doc); + if (ms == null) continue; + if (ms < startMs || ms >= endMs) continue; + + const roomIds = parseRoomIdsFromBooking(doc); + for (const roomId of roomIds) { + if (!resourcesByRoomId.has(roomId)) continue; + roomCounts.set(roomId, (roomCounts.get(roomId) ?? 0) + 1); + } + } + + countsByPeriod[period] = roomCounts; + } + + for (const roomId of uniqueSelectedRoomIds) { + const resource = resourcesByRoomId.get(roomId); + if (!resource) continue; + + for (const period of periodsToQuery) { + const limit = parseConfiguredRequestLimit( + resource.requestLimits?.[period]?.[limitRoleKey], + ); + if (limit === undefined) continue; + + const count = countsByPeriod[period]?.get(roomId) ?? 0; + // Allow another request only when existing active count is strictly below the cap. + // For limit 0, count < 0 is impossible, so booking is always blocked. + if (count < limit) continue; + + const resourceName = resource.name + ? `"${resource.name}"` + : `Room ${roomId}`; + return { + ok: false, + message: `Request limit reached for ${resourceName} (${period}). Limit: ${limit}.`, + } as const; + } + } + + return { ok: true } as const; +} diff --git a/booking-app/lib/firebase/firebase.ts b/booking-app/lib/firebase/firebase.ts index b6ad3c017..5c5c12609 100644 --- a/booking-app/lib/firebase/firebase.ts +++ b/booking-app/lib/firebase/firebase.ts @@ -1,6 +1,7 @@ import { ApproverLevel, TableNames, + getResourceApproverDocumentId, getTenantCollectionName, } from "@/components/src/policy"; import { Timestamp } from "firebase/firestore"; @@ -48,6 +49,34 @@ export type AdminUserData = { createdAt: Timestamp; }; +export type ResourceApproverData = { + id: string; + email: string; + resourceId: string; + createdAt: Timestamp; +}; + +const FIRESTORE_IN_QUERY_LIMIT = 30; + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); +const normalizeResourceId = (resourceId: string): string => resourceId.trim(); + +const normalizeResourceIds = (resourceIds: string[]): string[] => [ + ...new Set(resourceIds.map(normalizeResourceId).filter(Boolean)), +]; + +type ResourceApproverSetRequest = { + op: "set"; + collection: TableNames.RESOURCE_APPROVERS; + tenant?: string; + docId: string; + data: { + email: string; + resourceId: string; + createdAt: { __ts: number }; + }; +}; + /** * Walk the parsed JSON tree and convert serialized Firestore Timestamps * back into client SDK `Timestamp` instances so callers can keep using @@ -214,6 +243,119 @@ export const clientFetchAllDataFromCollection = async ( return docs as unknown as T[]; }; +export const clientListResourceApprovers = async ( + tenant?: string, +): Promise => + clientFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [], + tenant, + ); + +const clientListResourceApproversByResourceIds = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + if (uniqueResourceIds.length <= FIRESTORE_IN_QUERY_LIMIT) { + return clientFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "resourceId", op: "in", value: uniqueResourceIds }], + tenant, + ); + } + + const requestedResourceIds = new Set(uniqueResourceIds); + const approvers = await clientListResourceApprovers(tenant); + return approvers.filter((approver) => + requestedResourceIds.has(approver.resourceId), + ); +}; + +export const clientAddResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); + const normalizedEmail = normalizeEmail(email); + await postJson("/api/firestore/mutate", { + op: "set", + collection: TableNames.RESOURCE_APPROVERS, + tenant: resolveTenantArg(tenant), + docId: getResourceApproverDocumentId(normalizedResourceId, normalizedEmail), + data: { + email: normalizedEmail, + resourceId: normalizedResourceId, + createdAt: { __ts: Date.now() }, + }, + }); +}; + +export const clientRemoveResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); + const normalizedEmail = normalizeEmail(email); + await postJson("/api/firestore/mutate", { + op: "delete", + collection: TableNames.RESOURCE_APPROVERS, + tenant: resolveTenantArg(tenant), + docId: getResourceApproverDocumentId(normalizedResourceId, normalizedEmail), + }); +}; + +export const clientResolveResourceApproverEmails = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + const approvers = await clientListResourceApproversByResourceIds( + uniqueResourceIds, + tenant, + ); + const requestedResourceIds = new Set(uniqueResourceIds); + const resourceIdsByEmail = new Map>(); + + for (const approver of approvers) { + if (!requestedResourceIds.has(approver.resourceId)) continue; + const email = normalizeEmail(approver.email); + const approverResourceIds = resourceIdsByEmail.get(email) ?? new Set(); + approverResourceIds.add(approver.resourceId); + resourceIdsByEmail.set(email, approverResourceIds); + } + + const recipients = [...resourceIdsByEmail.entries()] + .filter(([, approverResourceIds]) => + uniqueResourceIds.every((resourceId) => approverResourceIds.has(resourceId)), + ) + .map(([email]) => email); + + if (recipients.length === 0) { + const { docs } = await postJson< + ListRequest, + { docs: Array<{ email?: string }> } + >("/api/firestore/list", { + collection: TableNames.APPROVERS, + tenant: resolveTenantArg(tenant), + where: [{ field: "level", op: "==", value: ApproverLevel.FINAL }], + limit: 1, + }); + const finalApproverEmail = docs[0]?.email + ? normalizeEmail(docs[0].email) + : undefined; + if (finalApproverEmail) recipients.push(finalApproverEmail); + } + + return recipients; +}; + export const clientFetchAllDataFromCollectionWithLimitAndOffset = async ( collectionName: TableNames, limitNumber: number, diff --git a/booking-app/lib/firebase/server/adminDb.ts b/booking-app/lib/firebase/server/adminDb.ts index bb99cfe12..25386bb87 100644 --- a/booking-app/lib/firebase/server/adminDb.ts +++ b/booking-app/lib/firebase/server/adminDb.ts @@ -1,6 +1,7 @@ import { ApproverLevel, TableNames, + getResourceApproverDocumentId, getTenantCollectionName, } from "@/components/src/policy"; import { @@ -31,6 +32,144 @@ export type AdminUserData = { createdAt: admin.firestore.Timestamp; }; +export type ResourceApproverData = { + id: string; + email: string; + resourceId: string; + createdAt: admin.firestore.Timestamp; +}; + +const FIRESTORE_IN_QUERY_LIMIT = 30; + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); +const normalizeResourceId = (resourceId: string): string => resourceId.trim(); + +const normalizeResourceIds = (resourceIds: string[]): string[] => [ + ...new Set(resourceIds.map(normalizeResourceId).filter(Boolean)), +]; + +export const serverListResourceApprovers = async ( + tenant?: string, +): Promise => + serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [], + tenant, + ); + +export const serverListResourceApproversByResourceIds = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + if (uniqueResourceIds.length <= FIRESTORE_IN_QUERY_LIMIT) { + return serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "resourceId", operator: "in", value: uniqueResourceIds }], + tenant, + ); + } + + const requestedResourceIds = new Set(uniqueResourceIds); + const approvers = await serverListResourceApprovers(tenant); + return approvers.filter((approver) => + requestedResourceIds.has(approver.resourceId), + ); +}; + +export const serverListResourceApproversByEmail = async ( + email: string, + tenant?: string, +): Promise => + serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "email", operator: "==", value: normalizeEmail(email) }], + tenant, + ); + +export const serverAddResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); + const normalizedEmail = normalizeEmail(email); + const tenantCollection = getServerTenantCollection( + TableNames.RESOURCE_APPROVERS, + tenant, + ); + const docId = getResourceApproverDocumentId( + normalizedResourceId, + normalizedEmail, + ); + await traceDatabase("set", `Firestore/${tenantCollection}`, () => + db.collection(tenantCollection).doc(docId).set({ + email: normalizedEmail, + resourceId: normalizedResourceId, + createdAt: admin.firestore.Timestamp.now(), + }), + ); +}; + +export const serverRemoveResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); + const normalizedEmail = normalizeEmail(email); + const tenantCollection = getServerTenantCollection( + TableNames.RESOURCE_APPROVERS, + tenant, + ); + const docId = getResourceApproverDocumentId( + normalizedResourceId, + normalizedEmail, + ); + await traceDatabase("delete", `Firestore/${tenantCollection}`, () => + db.collection(tenantCollection).doc(docId).delete(), + ); +}; + +export const serverResolveResourceApproverEmails = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + const approvers = await serverListResourceApproversByResourceIds( + uniqueResourceIds, + tenant, + ); + const requestedResourceIds = new Set(uniqueResourceIds); + const resourceIdsByEmail = new Map>(); + + for (const approver of approvers) { + if (!requestedResourceIds.has(approver.resourceId)) continue; + const email = normalizeEmail(approver.email); + const approverResourceIds = resourceIdsByEmail.get(email) ?? new Set(); + approverResourceIds.add(approver.resourceId); + resourceIdsByEmail.set(email, approverResourceIds); + } + + const recipients = [...resourceIdsByEmail.entries()] + .filter(([, approverResourceIds]) => + uniqueResourceIds.every((resourceId) => approverResourceIds.has(resourceId)), + ) + .map(([email]) => email); + + if (recipients.length === 0) { + const finalApproverEmail = + await serverGetFinalApproverEmailFromDatabaseStrict(tenant); + if (finalApproverEmail) recipients.push(finalApproverEmail); + } + + return recipients; +}; + export const serverDeleteData = async ( collectionName: string, docId: string, @@ -286,6 +425,27 @@ export const serverGetFinalApproverEmailFromDatabase = async ( } }; +const serverGetFinalApproverEmailFromDatabaseStrict = async ( + tenant?: string, +): Promise => { + const tenantCollection = getServerTenantCollection( + TableNames.APPROVERS, + tenant, + ); + const querySnapshot = await traceDatabase( + "query", + `Firestore/${tenantCollection}`, + () => + db + .collection(tenantCollection) + .where("level", "==", ApproverLevel.FINAL) + .limit(1) + .get(), + ); + const email = querySnapshot.docs[0]?.data().email; + return typeof email === "string" ? email.trim().toLowerCase() : null; +}; + export const serverGetFinalApproverEmail = async ( tenant?: string, ): Promise => { diff --git a/booking-app/lib/stateMachines/mcBookingMachine.ts b/booking-app/lib/stateMachines/mcBookingMachine.ts index 4677d03c0..6dc3c0873 100644 --- a/booking-app/lib/stateMachines/mcBookingMachine.ts +++ b/booking-app/lib/stateMachines/mcBookingMachine.ts @@ -393,6 +393,9 @@ export const mcBookingMachine = setup({ // Service approval/decline actions generated from config ...createServiceActions(SERVICE_CONFIGS), // Auto-approve all requested services for pregame bookings + resetServiceDecisionsOnEdit: assign({ + servicesApproved: () => ({}), + }), approveAllPregameServices: assign({ servicesApproved: ({ context }) => { const approved: any = {}; @@ -746,6 +749,7 @@ export const mcBookingMachine = setup({ }, edit: { target: "Requested", + actions: "resetServiceDecisionsOnEdit", }, approve: { target: "Pre-approved", @@ -825,6 +829,7 @@ export const mcBookingMachine = setup({ }, edit: { target: "Requested", + actions: "resetServiceDecisionsOnEdit", }, }, after: { diff --git a/booking-app/lib/stateMachines/xstatePersistence.ts b/booking-app/lib/stateMachines/xstatePersistence.ts index 525138d0b..804fa111e 100644 --- a/booking-app/lib/stateMachines/xstatePersistence.ts +++ b/booking-app/lib/stateMachines/xstatePersistence.ts @@ -14,6 +14,37 @@ import { itpBookingMachine } from "./itpBookingMachine"; import { mcBookingMachine } from "./mcBookingMachine"; import type { PersistedXStateData } from "./xstateTypes"; +const SERVICE_APPROVAL_FIELD_MAP = { + staff: "staffServiceApproved", + equipment: "equipmentServiceApproved", + catering: "cateringServiceApproved", + cleaning: "cleaningServiceApproved", + security: "securityServiceApproved", + setup: "setupServiceApproved", +} as const; + +/** + * Build XState servicesApproved context from Firestore booking fields. + * Only explicit boolean decisions are included so cleared/null fields do not + * retain a prior declined state after edit/resubmission. + */ +export function getServicesApprovedFromBookingData( + bookingData: any, +): Record { + const servicesApproved: Record = {}; + + for (const [serviceKey, fieldName] of Object.entries( + SERVICE_APPROVAL_FIELD_MAP, + )) { + const value = bookingData?.[fieldName]; + if (typeof value === "boolean") { + servicesApproved[serviceKey] = value; + } + } + + return servicesApproved; +} + /** * Map booking status to XState state */ @@ -116,14 +147,7 @@ export async function createXStateDataFromBookingStatus( email: bookingData.email, isVip: bookingData.isVip || false, servicesRequested, - servicesApproved: { - staff: bookingData.staffServiceApproved, - equipment: bookingData.equipmentServiceApproved, - catering: bookingData.cateringServiceApproved, - cleaning: bookingData.cleaningServiceApproved, - security: bookingData.securityServiceApproved, - setup: bookingData.setupServiceApproved, - }, + servicesApproved: getServicesApprovedFromBookingData(bookingData), // Flag to indicate this XState was created from existing booking without prior xstateData _restoredFromStatus: true, } @@ -451,14 +475,8 @@ export async function restoreXStateFromFirestore( // Update MC-specific services data if (isMediaCommons(tenant)) { const currentServicesRequested = getMediaCommonsServices(bookingData); - const currentServicesApproved = { - staff: (bookingData as any).staffServiceApproved, - equipment: (bookingData as any).equipmentServiceApproved, - catering: (bookingData as any).cateringServiceApproved, - cleaning: (bookingData as any).cleaningServiceApproved, - security: (bookingData as any).securityServiceApproved, - setup: (bookingData as any).setupServiceApproved, - }; + const currentServicesApproved = + getServicesApprovedFromBookingData(bookingData); updatedSnapshot.context = { ...updatedSnapshot.context, diff --git a/booking-app/lib/tenant/coerceTenantSchema.ts b/booking-app/lib/tenant/coerceTenantSchema.ts index 9a565cd5f..84033a4c6 100644 --- a/booking-app/lib/tenant/coerceTenantSchema.ts +++ b/booking-app/lib/tenant/coerceTenantSchema.ts @@ -2,190 +2,73 @@ import type { EmailNotifications, Resource, SchemaContextType, - TimeSensitiveRequestWarning, } from "@/components/src/client/routes/components/schemaTypes"; import { generateDefaultSchema } from "@/components/src/client/routes/components/schemaTypes"; -function mapLegacyEmailMessages( - legacy: Record | undefined, -): Partial { - if (!legacy) return {}; - return { - requestedUser: legacy.requestConfirmation || "", - requestedNeedsApproval: legacy.firstApprovalRequest || "", - reviewedNeedsApproval: legacy.secondApprovalRequest || "", - approvedWalkIn: legacy.walkInConfirmation || "", - approvedVIP: legacy.vipConfirmation || "", - checkedOut: legacy.checkoutConfirmation || "", - checkedIn: legacy.checkinConfirmation || "", - declined: legacy.declined || "", - canceled: legacy.canceled || "", - canceledLate: legacy.lateCancel || "", - noShow: legacy.noShow || "", - closed: legacy.closed || "", - approvedUser: legacy.approvalNotice || "", - }; -} - -/** - * Resolve the attestations array, preferring a populated `attestations`, then a - * populated legacy `agreements`, so an empty array (e.g. an injected default) - * never shadows real configured data. Falls back to the default schema. - */ -function pickAttestations( - raw: Record, - base: SchemaContextType, -): SchemaContextType["attestations"] { - const attestations = raw.attestations; - if (Array.isArray(attestations) && attestations.length > 0) { - return attestations as SchemaContextType["attestations"]; +function normalizeResourceId(value: unknown, field: string): string { + if ( + (typeof value !== "string" && typeof value !== "number") || + (typeof value === "number" && !Number.isFinite(value)) + ) { + throw new Error(`${field} must be a string or finite number`); } - const agreements = raw.agreements; - if (Array.isArray(agreements) && agreements.length > 0) { - return agreements as SchemaContextType["attestations"]; + const resourceId = String(value); + if (!resourceId.trim()) { + throw new Error(`${field} must be non-empty`); } - if (Array.isArray(attestations)) { - return attestations as SchemaContextType["attestations"]; + if (resourceId.includes(",")) { + throw new Error(`${field} must not contain commas`); } - return base.attestations; + return resourceId; } -function migrateResource(r: Record): Resource { - // Merge an existing nested `training` object with legacy flat fields so that - // partially-migrated resources (e.g. `{ training: { required: true }, - // trainingInfoUrl: "..." }`) keep their legacy form/info URLs instead of - // dropping them. Nested values win per-field; legacy fills any gaps. - const nested = - r.training && typeof r.training === "object" && !Array.isArray(r.training) - ? (r.training as Partial) - : undefined; - - const hasLegacyTraining = - r.needsSafetyTraining !== undefined || - r.trainingFormUrl !== undefined || - r.trainingInfoUrl !== undefined; - - const legacy = hasLegacyTraining - ? { - required: Boolean(r.needsSafetyTraining), - formId: (r.trainingFormUrl as string) || "", - infoUrl: (r.trainingInfoUrl as string) || "", - } - : undefined; - - const training: Resource["training"] = { - required: nested?.required ?? legacy?.required ?? false, - formId: nested?.formId || legacy?.formId || "", - infoUrl: nested?.infoUrl || legacy?.infoUrl || "", - }; - +function coerceResource( + rawResource: Resource | Record, + index: number, +): Resource { const { - needsSafetyTraining: _n, - trainingFormUrl: _f, - trainingInfoUrl: _i, - ...rest - } = r; - - return { ...rest, training } as Resource; -} + roomId, + resourceId, + ...resource + } = rawResource as Record; + const canonicalId = + resourceId === undefined + ? normalizeResourceId(roomId, `resources[${index}].roomId`) + : normalizeResourceId(resourceId, `resources[${index}].resourceId`); + + if ( + roomId !== undefined && + normalizeResourceId(roomId, `resources[${index}].roomId`) !== canonicalId + ) { + throw new Error( + `resources[${index}] has conflicting resourceId and roomId`, + ); + } -function isNewSchemaShape(raw: Record): boolean { - // The nested shape is identified by `tenant`/`mappings`/`form` being objects. - // `contextLabels` is optional and defaultable, so it must NOT be required - // here — otherwise a nested document that simply omits it would be treated as - // legacy and have its nested `tenant`/`mappings`/`form` blanked out. - return Boolean( - raw.mappings && - typeof raw.mappings === "object" && - !Array.isArray(raw.mappings) && - raw.form && - typeof raw.form === "object" && - !Array.isArray(raw.form) && - raw.tenant && - typeof raw.tenant === "object" && - !Array.isArray(raw.tenant), - ); + return { + ...resource, + resourceId: canonicalId, + } as Resource; } -/** - * Top-level keys from the legacy flat schema. If any are still present on a - * document that otherwise looks "nested", Firestore should be rewritten so the - * canonical nested-only shape is stored (avoids ambiguous round-trips). - */ -export const STALE_LEGACY_TOP_LEVEL_TENANT_SCHEMA_KEYS = [ - "name", - "logo", - "nameForPolicy", - "permissionLabels", - "programMapping", - "roleMapping", - "schoolMapping", - "showBookingTypes", - "showNNumber", - "showSponsor", - "showCatering", - "showEquipment", - "showHireSecurity", - "showSetup", - "showStaffing", - "supportVIP", - "supportWalkIn", - "safetyTrainingGoogleFormId", - "timeSensitiveRequestWarning", - "emailMessages", - "agreements", -] as const; - -/** Legacy field names that may still live inside a `resources[]` entry. */ -const STALE_LEGACY_RESOURCE_KEYS = [ - "needsSafetyTraining", - "trainingFormUrl", - "trainingInfoUrl", -] as const; - -/** True when the stored document already matches the nested tenant schema shape. */ -export function isNestedTenantSchemaDocument( - raw: Record | null | undefined, -): boolean { - return Boolean(raw && typeof raw === "object" && isNewSchemaShape(raw)); +function coerceResources( + resources: Array>, +): Resource[] { + const seen = new Set(); + return resources.map((resource, index) => { + const coerced = coerceResource(resource, index); + if (seen.has(coerced.resourceId)) { + throw new Error(`resources has duplicate resourceId "${coerced.resourceId}"`); + } + seen.add(coerced.resourceId); + return coerced; + }); } /** - * Whether a Firestore tenantSchema document should be rewritten to the canonical - * nested shape (coerce + full replace). Used by one-off migration tooling. - */ -export function tenantSchemaFirestoreDocNeedsShapeMigration( - raw: Record | null | undefined, -): boolean { - if (!raw || typeof raw !== "object") return false; - if (typeof raw.tenant === "string") return true; - if (!isNewSchemaShape(raw)) return true; - const hasStaleTopLevel = STALE_LEGACY_TOP_LEVEL_TENANT_SCHEMA_KEYS.some( - (k) => - Object.prototype.hasOwnProperty.call(raw, k) && - raw[k as string] !== undefined, - ); - if (hasStaleTopLevel) return true; - // A doc may be nested at the top level but still carry legacy fields inside - // its resource entries (e.g. needsSafetyTraining / trainingFormUrl). Those - // also need canonicalizing, so flag them too. - const resources = raw.resources; - if (Array.isArray(resources)) { - return resources.some( - (r) => - r && - typeof r === "object" && - STALE_LEGACY_RESOURCE_KEYS.some((k) => - Object.prototype.hasOwnProperty.call(r, k), - ), - ); - } - return false; -} - -/** - * Normalizes a tenant schema document from Firestore (new or legacy shape) - * into SchemaContextType. + * Normalizes a tenant schema document from Firestore into SchemaContextType by + * merging the stored (canonical nested-shape) document over the tenant + * defaults, so schema fields that a document omits fall back to their defaults. */ export function coerceTenantSchema( raw: Record | null | undefined, @@ -196,195 +79,54 @@ export function coerceTenantSchema( return base; } - if (isNewSchemaShape(raw)) { - const rawCc = raw.calendarConfig as SchemaContextType["calendarConfig"] | undefined; - return { - ...base, - ...raw, - tenantId: (raw.tenantId as string) || tenantSlug, - tenant: { - ...base.tenant, - ...(raw.tenant as SchemaContextType["tenant"]), - }, - mappings: { - ...base.mappings, - ...(raw.mappings as SchemaContextType["mappings"]), - }, - form: { - ...base.form, - ...(raw.form as SchemaContextType["form"]), - services: { - ...base.form.services, - ...((raw.form as SchemaContextType["form"]).services ?? {}), - }, - }, - origins: { - ...base.origins, - ...(raw.origins as SchemaContextType["origins"]), - }, - emailNotifications: { - ...base.emailNotifications, - // Partially-migrated documents may still carry legacy `emailMessages`; - // map them in first so nested `emailNotifications` only overrides them - // when it actually has values. - ...mapLegacyEmailMessages( - raw.emailMessages as Record | undefined, - ), - ...(raw.emailNotifications as EmailNotifications), - }, - calendarConfig: { - ...base.calendarConfig, - ...rawCc, - timeSensitiveRequestWarning: { - ...base.calendarConfig?.timeSensitiveRequestWarning, - // A partially-migrated document may have new-shape tenant/mappings/form - // but still carry the warning at the legacy top level. Pick that up so - // the warning banner is not silently dropped; the nested value (the - // canonical location) wins when present. - ...(typeof raw.timeSensitiveRequestWarning === "object" && - raw.timeSensitiveRequestWarning !== null - ? (raw.timeSensitiveRequestWarning as Record) - : {}), - ...rawCc?.timeSensitiveRequestWarning, - }, - }, - resources: Array.isArray(raw.resources) - ? raw.resources.map((x) => migrateResource(x as Record)) - : base.resources, - // Prefer a populated attestations array; fall back to legacy `agreements` - // when present so an empty nested array does not shadow real data. - attestations: pickAttestations(raw, base), - } as SchemaContextType; - } - - const legacyTenantString = - typeof raw.tenant === "string" ? (raw.tenant as string) : undefined; - - const rawCal = raw.calendarConfig as + const rawCc = raw.calendarConfig as | SchemaContextType["calendarConfig"] | undefined; - const topWarning = raw.timeSensitiveRequestWarning as - | TimeSensitiveRequestWarning - | undefined; - - const calendarConfig: SchemaContextType["calendarConfig"] = { - ...base.calendarConfig, - ...rawCal, - timeSensitiveRequestWarning: { - ...base.calendarConfig?.timeSensitiveRequestWarning, - ...rawCal?.timeSensitiveRequestWarning, - ...(typeof topWarning === "object" && topWarning !== null - ? (topWarning as Record) - : {}), - }, - }; + const rawForm = raw.form as SchemaContextType["form"] | undefined; - const core: SchemaContextType = { + return { ...base, - tenantId: (raw.tenantId as string) || legacyTenantString || tenantSlug, + ...raw, + tenantId: (raw.tenantId as string) || tenantSlug, tenant: { - name: (raw.name as string) ?? "", - logo: (raw.logo as string) ?? "", - nameForPolicy: (raw.nameForPolicy as string) ?? "", - contextLabels: - (raw.permissionLabels as SchemaContextType["tenant"]["contextLabels"]) || - base.tenant.contextLabels, + ...base.tenant, + ...(raw.tenant as SchemaContextType["tenant"]), }, - policy: (raw.policy as string) ?? base.policy, mappings: { - program: - (raw.programMapping as Record) ?? base.mappings.program, - role: (raw.roleMapping as Record) ?? base.mappings.role, - school: (raw.schoolMapping as Record) ?? base.mappings.school, + ...base.mappings, + ...(raw.mappings as SchemaContextType["mappings"]), }, - roles: (raw.roles as string[]) ?? base.roles, form: { - showBookingType: (raw.showBookingTypes as boolean) ?? base.form.showBookingType, - showNNumber: (raw.showNNumber as boolean) ?? base.form.showNNumber, - showSponsor: (raw.showSponsor as boolean) ?? base.form.showSponsor, + ...base.form, + ...rawForm, services: { - showCatering: (raw.showCatering as boolean) ?? base.form.services.showCatering, - showEquipment: (raw.showEquipment as boolean) ?? base.form.services.showEquipment, - showSecurity: (raw.showHireSecurity as boolean) ?? base.form.services.showSecurity, - showSetup: (raw.showSetup as boolean) ?? base.form.services.showSetup, - showStaffing: (raw.showStaffing as boolean) ?? base.form.services.showStaffing, + ...base.form.services, + ...(rawForm?.services ?? {}), }, }, - attestations: pickAttestations(raw, base), - resources: Array.isArray(raw.resources) - ? raw.resources.map((x) => migrateResource(x as Record)) - : base.resources, origins: { - VIP: (raw.supportVIP as boolean) ?? base.origins.VIP, - walkIn: (raw.supportWalkIn as boolean) ?? base.origins.walkIn, - }, - training: { - formId: - (raw.training as { formId?: string } | undefined)?.formId || - (raw.safetyTrainingGoogleFormId as string) || - base.training?.formId || - "", + ...base.origins, + ...(raw.origins as SchemaContextType["origins"]), }, - supportPA: (raw.supportPA as boolean) ?? base.supportPA, - supportLiaison: (raw.supportLiaison as boolean) ?? base.supportLiaison, - resourceName: (raw.resourceName as string) ?? base.resourceName, - declinedGracePeriod: - (raw.declinedGracePeriod as number) ?? base.declinedGracePeriod, - autoCancel: (raw.autoCancel as SchemaContextType["autoCancel"]) ?? base.autoCancel, - calendarConfig, - ccEmails: (raw.ccEmails as SchemaContextType["ccEmails"]) ?? base.ccEmails, emailNotifications: { ...base.emailNotifications, - ...mapLegacyEmailMessages(raw.emailMessages as Record | undefined), - ...((raw.emailNotifications as EmailNotifications) || {}), + ...(raw.emailNotifications as EmailNotifications), }, - }; - - const legacyTopLevelKeys = new Set([ - "tenantId", - "tenant", - "name", - "logo", - "nameForPolicy", - "permissionLabels", - "policy", - "programMapping", - "roleMapping", - "schoolMapping", - "roles", - "showBookingTypes", - "showNNumber", - "showSponsor", - "showCatering", - "showEquipment", - "showHireSecurity", - "showSetup", - "showStaffing", - "agreements", - "attestations", - "resources", - "supportVIP", - "supportWalkIn", - "training", - "safetyTrainingGoogleFormId", - "supportPA", - "supportLiaison", - "resourceName", - "declinedGracePeriod", - "autoCancel", - "calendarConfig", - "ccEmails", - "emailMessages", - "emailNotifications", - "timeSensitiveRequestWarning", - "mappings", - "form", - "origins", - ]); - - const passthrough = Object.fromEntries( - Object.entries(raw).filter(([k]) => !legacyTopLevelKeys.has(k)), - ); - - return { ...core, ...passthrough } as SchemaContextType; + calendarConfig: { + ...base.calendarConfig, + ...rawCc, + timeSensitiveRequestWarning: { + ...base.calendarConfig?.timeSensitiveRequestWarning, + ...rawCc?.timeSensitiveRequestWarning, + }, + }, + resources: Array.isArray(raw.resources) + ? coerceResources( + raw.resources as Array>, + ) + : base.resources, + attestations: Array.isArray(raw.attestations) + ? (raw.attestations as SchemaContextType["attestations"]) + : base.attestations, + } as SchemaContextType; } diff --git a/booking-app/lib/utils/testTenantSchema.ts b/booking-app/lib/utils/testTenantSchema.ts index 2c6ae2a54..1f80010c8 100644 --- a/booking-app/lib/utils/testTenantSchema.ts +++ b/booking-app/lib/utils/testTenantSchema.ts @@ -83,7 +83,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 30, name: "Lecture Hall 202", - roomId: 202, + resourceId: "202", calendarId: "mock-calendar-202", isWalkIn: false, autoApproval: { shouldAutoApprove: true }, @@ -91,7 +91,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 20, name: "Studio 220", - roomId: 220, + resourceId: "220", calendarId: "mock-calendar-220", isWalkIn: true, autoApproval: { shouldAutoApprove: false }, @@ -99,7 +99,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 25, name: "Seminar 203", - roomId: 203, + resourceId: "203", calendarId: "mock-calendar-203", isWalkIn: false, maxHour: { ...defaultResource.maxHour, faculty: 0.5 }, @@ -108,7 +108,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 15, name: "Workshop 230", - roomId: 230, + resourceId: "230", calendarId: "mock-calendar-230", isWalkIn: false, training: { @@ -186,7 +186,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 20, name: "Room 408", - roomId: 408, + resourceId: "408", calendarId: "mock-calendar-408", isWalkIn: false, autoApproval: { @@ -198,7 +198,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 10, name: "Room 410", - roomId: 410, + resourceId: "410", calendarId: "mock-calendar-410", isWalkIn: true, autoApproval: { diff --git a/booking-app/next.config.mjs b/booking-app/next.config.mjs index 2d829050f..2ebb9df1d 100644 --- a/booking-app/next.config.mjs +++ b/booking-app/next.config.mjs @@ -22,32 +22,17 @@ const nextConfig = { ignoreDuringBuilds: true, }, serverExternalPackages: ["newrelic"], + // Only non-secret, browser-facing values belong here. Next.js inlines every + // `env` entry into any bundle that references it (including client bundles), + // so server secrets must NOT be listed — a single client `process.env.X` + // reference would ship them to the browser. Server code reads secrets from + // `process.env` directly at runtime (loaded from the deploy-time env file), + // which does not require an `env` entry. env: { NEXT_PUBLIC_IS_TEST_ENV: isTestEnv ? "true" : "false", - FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID, - FIREBASE_PRIVATE_KEY_ID: process.env.FIREBASE_PRIVATE_KEY_ID, - FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY, - FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL, - FIREBASE_CLIENT_ID: process.env.FIREBASE_CLIENT_ID, - FIREBASE_AUTH_URI: process.env.FIREBASE_AUTH_URI, - FIREBASE_TOKEN_URI: process.env.FIREBASE_TOKEN_URI, - FIREBASE_AUTH_PROVIDER_X509_CERT_URL: - process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL, - FIREBASE_CLIENT_X509_CERT_URL: process.env.FIREBASE_CLIENT_X509_CERT_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BRANCH_NAME: process.env.NEXT_PUBLIC_BRANCH_NAME, NEXT_PUBLIC_GCP_LOG_NAME: process.env.NEXT_PUBLIC_GCP_LOG_NAME, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI, - GOOGLE_REFRESH_TOKEN: process.env.GOOGLE_REFRESH_TOKEN, - GOOGLE_SHEET_ID: process.env.GOOGLE_SHEET_ID, - GOOGLE_SPREADSHEET_ID: process.env.GOOGLE_SPREADSHEET_ID, - GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, - NYU_API_CLIENT_ID: process.env.NYU_API_CLIENT_ID, - NYU_API_CLIENT_SECRET: process.env.NYU_API_CLIENT_SECRET, - NYU_API_USER_NAME: process.env.NYU_API_USER_NAME, - NYU_API_PASSWORD: process.env.NYU_API_PASSWORD, }, compiler: { styledComponents: true, diff --git a/booking-app/package-lock.json b/booking-app/package-lock.json index eb9a98e81..f26a15720 100644 --- a/booking-app/package-lock.json +++ b/booking-app/package-lock.json @@ -36,7 +36,7 @@ "handlebars": "^4.7.9", "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", - "newrelic": "^13.12.0", + "newrelic": "^14.1.2", "next": "15.5.18", "next-auth": "^5.0.0-beta.30", "prop-types": "^15.8.1", @@ -60,7 +60,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5.1.34", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.0.2", "@xstate/test": "^1.0.0-beta.5", "dotenv": "^16.4.5", "eslint": "^9.0.0", @@ -81,7 +81,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "^4.1.9" } }, "node_modules/@adobe/css-tools": { @@ -100,18 +100,35 @@ } }, "node_modules/@apm-js-collab/code-transformer": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", - "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", - "license": "Apache-2.0" + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.14.0.tgz", + "integrity": "sha512-6G+FETQ/VyRBsIkDDZ5sc9fb7O6d6W9rm8bZPHumISZw/6nwxvnS+VTyxd3sKM04aZwHG5hJIHb7VPdPCLieSQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@apm-js-collab/code-transformer/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/@apm-js-collab/tracing-hooks": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", - "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.9.1.tgz", + "integrity": "sha512-9uzuUftJ+xF90g5u411OgU+TpdZqqGB4igq2c8swiS7BfDGe16bnDRfQSCU6jxk+hjiospSG/t9FI4Ja9iw3+g==", "license": "Apache-2.0", "dependencies": { - "@apm-js-collab/code-transformer": "^0.8.0", + "@apm-js-collab/code-transformer": "^0.14.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } @@ -1228,34 +1245,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-pure-annotations": { "version": "7.27.1", "license": "MIT", @@ -1761,22 +1750,48 @@ "node": ">=18" } }, + "node_modules/@datadog/pprof": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.16.0.tgz", + "integrity": "sha512-/m/YYXM/uPN5BVvRXjQaJWW9VF3rEOMaacvvjr9hdX2YFS0Tv9Tq440t/OIxunmQ5QGjRJs/qjabO+XE7Je0Vg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.8.4", + "pprof-format": "^2.2.1", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@datadog/pprof/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -1784,9 +1799,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1861,502 +1876,60 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.3.3", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.14.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, + "version": "1.3.3", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "react": ">=16.8.0" } }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -4097,9 +3670,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.8", @@ -5220,9 +4793,9 @@ } }, "node_modules/@newrelic/fn-inspect": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@newrelic/fn-inspect/-/fn-inspect-4.4.0.tgz", - "integrity": "sha512-VgoXZp3zqP1167XvrA772EHDFUNuYGQh14whFq1d2sE6dC3ZL46tXI9JY0yZdAAeyCERi7mhq7CPx9BYiTOl/A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@newrelic/fn-inspect/-/fn-inspect-5.1.0.tgz", + "integrity": "sha512-2ogTrZYTSr/hTh+kMIzzSOIGoary2MqUFxDV9abC2kBY3renTfTWMHM0dUkenBNFtjtQDyARDVwVqsdbbJSbSw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5232,13 +4805,13 @@ "prebuildify": "^6.0.1" }, "engines": { - "node": ">=16.9.1" + "node": ">=22" } }, "node_modules/@newrelic/native-metrics": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@newrelic/native-metrics/-/native-metrics-12.0.0.tgz", - "integrity": "sha512-l0MTkuazDMaEDWJk4ufHVgco4ssWhk/uSUYTn33dFOHAqpHLyxZHxxzKJb4vyZkhRy11UaKNhmNiAiXH557SJQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@newrelic/native-metrics/-/native-metrics-13.1.0.tgz", + "integrity": "sha512-MoBcOr3FmO7CUOLafnN9D8FGbpSYz2RTXk/yhXPm6lNkNge+IFgSGMowImYm8oYe20m3PN1Dv+bGqEhJ7UI+7g==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, @@ -5248,7 +4821,7 @@ "prebuildify": "^6.0.1" }, "engines": { - "node": ">=20", + "node": ">=22", "npm": ">=6" } }, @@ -5543,9 +5116,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.203.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.203.0.tgz", - "integrity": "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -5555,9 +5128,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5570,83 +5143,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.201.1.tgz", - "integrity": "sha512-LMRVg2yTev28L51RLLUK3gY0avMa1RVBq7IkYNtXDBxJRcd0TGGq/0rqfk7Y4UIM9NCJhDIUFHeGg8NpSgSWcw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-exporter-base": "0.201.1", - "@opentelemetry/otlp-transformer": "0.201.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-metrics": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.201.1.tgz", - "integrity": "sha512-9ie2jcaUQZdIoe6B02r0rF4Gz+JsZ9mev/2pYou1N0woOUkFM8xwO6BAlORnrFVslqF/XO5WG3q5FsTbuC5iiw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", + "integrity": "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.201.1", - "@opentelemetry/otlp-exporter-base": "0.201.1", - "@opentelemetry/otlp-transformer": "0.201.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-metrics": "2.0.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5655,61 +5161,14 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.201.1.tgz", - "integrity": "sha512-FiS/mIWmZXyRxYGyXPHY+I/4+XrYVTD7Fz/zwOHkVPQsA1JTakAOP9fAi6trXMio0dIpzvQujLNiBqGM7ExrQw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-transformer": "0.201.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5718,207 +5177,68 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.201.1.tgz", - "integrity": "sha512-+q/8Yuhtu9QxCcjEAXEO8fXLjlSnrnVwfzi9jiWaMAppQp69MoagHHomQj02V2WnGjvBod5ajgkbK4IoWab50A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.201.1", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-logs": "0.201.1", - "@opentelemetry/sdk-metrics": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.201.1.tgz", - "integrity": "sha512-IxcFDP1IGMDemVFG2by/AMK+/o6EuBQ8idUq3xZ6MxgQGeumYZuX5OwR0h9HuvcUc/JPjQGfU5OHKIKYDJcXeA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.201.1.tgz", - "integrity": "sha512-Ug8gtpssUNUnfpotB9ZhnSsPSGDu+7LngTMgKl31mmVJwLAKyl6jC8diZrMcGkSgBh0o5dbg9puvLyR25buZfw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.201.1", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", - "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.203.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.203.0.tgz", - "integrity": "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.203.0", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", - "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5928,13 +5248,13 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", - "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -5953,6 +5273,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -6068,29 +5398,10 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -6099,12 +5410,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -6113,12 +5427,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -6127,26 +5444,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -6155,26 +5461,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -6183,26 +5478,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -6211,54 +5495,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -6267,40 +5529,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -6309,12 +5546,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -6323,12 +5563,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -6337,26 +5580,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -6365,54 +5597,70 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -6421,7 +5669,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -6435,6 +5693,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@streamparser/json": { "version": "0.0.6", "license": "MIT" @@ -6788,9 +6053,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -6804,43 +6069,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/caseless": { "version": "0.12.5", "license": "MIT", @@ -6848,6 +6076,8 @@ }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -6857,12 +6087,13 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "dev": true, "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { @@ -6961,14 +6192,16 @@ } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.5", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -6976,6 +6209,19 @@ "node": ">= 0.12" } }, + "node_modules/@types/request/node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/@types/styled-components": { "version": "5.1.36", "dev": true, @@ -8038,54 +7284,66 @@ ] }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -8097,36 +7355,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -8134,29 +7398,37 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@xstate/graph": { "version": "2.0.0-beta.6", "dev": true, @@ -8206,6 +7478,7 @@ }, "node_modules/acorn": { "version": "8.15.0", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -8214,15 +7487,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8478,6 +7742,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -8489,6 +7755,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -8790,14 +8065,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "dev": true, @@ -8890,16 +8157,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -8928,14 +8190,6 @@ "node": ">=16" } }, - "node_modules/check-error": { - "version": "2.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "license": "MIT", @@ -8976,9 +8230,9 @@ "optional": true }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, "node_modules/client-only": { @@ -9396,14 +8650,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -9474,8 +8720,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -9758,8 +9004,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -9812,48 +9059,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/escalade": { "version": "3.2.0", "license": "MIT", @@ -10507,7 +9712,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -10531,7 +9735,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10539,6 +9742,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -10996,19 +10201,33 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, + "node_modules/form-data/node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -11317,7 +10536,9 @@ } }, "node_modules/google-gax/node_modules/@grpc/grpc-js": { - "version": "1.14.3", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -11667,15 +10888,17 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", - "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.0.tgz", + "integrity": "sha512-v4+k+PLt4z6g2ydEcXhfinTJxfgKJDWOW0v0GZNA5n2qToHFObpH4nGLhJdwnXnX4T14oREx/M62dS9GkpmSbQ==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.14.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" } }, "node_modules/imurmurhash": { @@ -12171,7 +11394,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12382,42 +11617,303 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/language-tags": { - "version": "1.0.9", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -12553,11 +12049,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "dev": true, - "license": "MIT" - }, "node_modules/lower-case": { "version": "2.0.2", "license": "MIT", @@ -12627,6 +12118,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12656,6 +12149,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "license": "MIT", @@ -12809,9 +12311,9 @@ } }, "node_modules/nan": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", - "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT", "optional": true }, @@ -12857,32 +12359,33 @@ "license": "MIT" }, "node_modules/newrelic": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/newrelic/-/newrelic-13.12.0.tgz", - "integrity": "sha512-tLIhkHK2+5nYGeeuTMiKI/F0qUnk5rK4m6UV0M4bk7D5r80+FV0EbB9eUtepkFRSp1hqPO4SfumlraAKRJuDAw==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/newrelic/-/newrelic-14.1.2.tgz", + "integrity": "sha512-RYEIchr5bd7sJ6r6XE/GQQ7aXq9pTcqXulmN5Z7wCLqulXAGKZpHDbfWs0MxGixTHj3Xnm+YY5C4XhhmrnHWUg==", "license": "Apache-2.0", "dependencies": { - "@apm-js-collab/tracing-hooks": "^0.3.1", + "@apm-js-collab/tracing-hooks": "^0.9.1", "@grpc/grpc-js": "^1.13.2", - "@grpc/proto-loader": "^0.7.5", + "@grpc/proto-loader": "^0.8.1", "@newrelic/security-agent": "^3.0.0", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/api-logs": "^0.219.0", "@opentelemetry/core": "^2.0.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.201.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/otlp-exporter-base": "^0.219.0", + "@opentelemetry/otlp-transformer": "^0.219.0", "@opentelemetry/resources": "^2.0.1", - "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.219.0", "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-trace-base": "^2.0.0", "@tyriar/fibonacci-heap": "^2.0.7", "concat-stream": "^2.0.0", - "https-proxy-agent": "^7.0.1", - "import-in-the-middle": "^1.13.0", + "https-proxy-agent": "^9.0.0", + "import-in-the-middle": "^3.0.1", "json-bigint": "^1.0.0", "json-stringify-safe": "^5.0.0", "module-details-from-path": "^1.0.3", - "readable-stream": "^3.6.1", - "require-in-the-middle": "^7.4.0", + "require-in-the-middle": "^8.0.1", "semver": "^7.5.2", "winston-transport": "^4.5.0" }, @@ -12890,19 +12393,20 @@ "newrelic-naming-rules": "bin/test-naming-rules.js" }, "engines": { - "node": ">=20", + "node": ">=22", "npm": ">=6.0.0" }, "optionalDependencies": { - "@newrelic/fn-inspect": "^4.4.0", - "@newrelic/native-metrics": "^12.0.0", + "@datadog/pprof": "^5.13.3", + "@newrelic/fn-inspect": "^5.0.0", + "@newrelic/native-metrics": "^13.0.0", "@prisma/prisma-fmt-wasm": "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085" } }, "node_modules/newrelic/node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -12912,15 +12416,15 @@ "node": ">=12.10.0" } }, - "node_modules/newrelic/node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "node_modules/newrelic/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.5.3", + "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { @@ -12930,6 +12434,29 @@ "node": ">=6" } }, + "node_modules/newrelic/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/newrelic/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/newrelic/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -13056,9 +12583,9 @@ } }, "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "license": "MIT", "optional": true, "dependencies": { @@ -13069,9 +12596,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -13296,6 +12823,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "license": "ISC", @@ -13457,17 +12998,11 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -13535,9 +13070,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -13678,6 +13213,13 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/pprof-format": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.2.tgz", + "integrity": "sha512-hd90rHVDhNOhgHTmazVzDSVwTLOBjpZQ26AO/0j46sAFZ9uSWY0DcK2zJcwnuo2R6EzufBbOoWlPazA5nSyHcg==", + "license": "MIT", + "optional": true + }, "node_modules/preact": { "version": "10.12.1", "license": "MIT", @@ -13838,6 +13380,23 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -13848,9 +13407,9 @@ } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "optional": true, "dependencies": { @@ -13950,14 +13509,6 @@ "version": "19.2.3", "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.17.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -14110,17 +13661,16 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", - "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.8" + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/reselect": { @@ -14201,49 +13751,38 @@ "integrity": "sha512-GCOqTzUsTHF7nrqcgtNGAFotXztLgiePpIDpyWZ7R5I02tmfJWV+/yuJc//Hlsd8G+WzI1t/dc2y/w2imDZdog==", "license": "MIT" }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rrweb-cssom": { @@ -14379,6 +13918,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "6.3.1", "license": "ISC", @@ -14639,7 +14184,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -14835,22 +14382,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -15116,9 +14647,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "optional": true, "dependencies": { @@ -15231,16 +14762,23 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -15276,24 +14814,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -15745,9 +15269,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -15932,18 +15456,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -15959,9 +15482,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -15974,13 +15498,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -16006,27 +15533,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "dev": true, @@ -16045,22 +15551,6 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "dev": true, @@ -16087,65 +15577,79 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -16156,6 +15660,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -16400,9 +15907,9 @@ "optional": true }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/booking-app/package.json b/booking-app/package.json index 3a2d81f45..d8ddcad35 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -19,8 +19,9 @@ "mergeUsers": "node scripts/mergeUsersCollections.js", "sync:schemas": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts", "sync:schemas:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts --dry-run", - "migrate:tenant-schema": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/migrateTenantSchemaFirestore.ts", - "migrate:tenant-schema:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/migrateTenantSchemaFirestore.ts --dry-run" + "migrate:tenant-resource-ids": "node scripts/migrateTenantResourceIds.js", + "migrate:tenant-resource-ids:dry-run": "node scripts/migrateTenantResourceIds.js --dry-run", + "backup:tenant-schema": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/backupTenantSchemaToDisk.ts" }, "dependencies": { "@emotion/styled": "^11.13.0", @@ -51,7 +52,7 @@ "handlebars": "^4.7.9", "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", - "newrelic": "^13.12.0", + "newrelic": "^14.1.2", "next": "15.5.18", "next-auth": "^5.0.0-beta.30", "prop-types": "^15.8.1", @@ -75,7 +76,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5.1.34", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.0.2", "@xstate/test": "^1.0.0-beta.5", "dotenv": "^16.4.5", "eslint": "^9.0.0", @@ -96,7 +97,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "^4.1.9" }, "overrides": { "eslint-config-airbnb-base": { diff --git a/booking-app/playwright.config.ts b/booking-app/playwright.config.ts index 229e872b1..47a63ebad 100644 --- a/booking-app/playwright.config.ts +++ b/booking-app/playwright.config.ts @@ -75,6 +75,7 @@ export default defineConfig({ "**/safety-training.e2e.test.ts", "**/ban-enforcement.e2e.test.ts", "**/form-validation.e2e.test.ts", + "**/request-limits.e2e.test.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/booking-app/scripts/README.md b/booking-app/scripts/README.md new file mode 100644 index 000000000..55f8f0b79 --- /dev/null +++ b/booking-app/scripts/README.md @@ -0,0 +1,5 @@ +# Scripts + +Long-form documentation for scripts lives under `docs/`. + +- **[Tenant schema CLI backup](../docs/TENANT_SCHEMA_BACKUP.md)** — `npm run backup:tenant-schema`, `backupTenantSchemaToDisk.ts` diff --git a/booking-app/scripts/backupTenantSchemaToDisk.ts b/booking-app/scripts/backupTenantSchemaToDisk.ts new file mode 100644 index 000000000..c41d0a07e --- /dev/null +++ b/booking-app/scripts/backupTenantSchemaToDisk.ts @@ -0,0 +1,291 @@ +/** + * Back up Firestore `tenantSchema` to `tenantSchemaBackup` (same as sync/copy). + * + * Default: writes via tenantSchemaBackup.js (Firestore). + * With --dry-run: writes JSON under scripts/output/ only (no Firestore writes). + * + * Documentation: docs/TENANT_SCHEMA_BACKUP.md + */ + +require("dotenv").config({ path: ".env.local" }); +import * as admin from "firebase-admin"; +import * as fs from "fs"; +import * as path from "path"; + +const { + TENANT_SCHEMA_COLLECTION, + TENANT_SCHEMA_BACKUP_COLLECTION, + formatBackupTimestamp, + createBackupDocId, + backupTenantSchemaDocument, + backupTenantSchemaCollection, +} = require("./tenantSchemaBackup"); + +/** Backup type in document ids; distinct from sync-defaults / copy. */ +const BACKUP_TYPE_CLI = "cli-backup"; + +const DATABASES = { + development: "default", + staging: "booking-app-staging", + production: "booking-app-prod", +} as const; + +type DatabaseEnv = keyof typeof DATABASES; + +interface BackupOptions { + database: DatabaseEnv; + tenant?: string; + outputDir?: string; + dryRun: boolean; +} + +function serializeValue(val: unknown): unknown { + if (val === null || val === undefined) { + return val; + } + if (typeof val === "bigint") { + return val.toString(); + } + if (val instanceof admin.firestore.Timestamp) { + return { + __firestoreTimestamp: true, + iso: val.toDate().toISOString(), + }; + } + if (val instanceof admin.firestore.GeoPoint) { + return { + __firestoreGeoPoint: true, + latitude: val.latitude, + longitude: val.longitude, + }; + } + if (val instanceof admin.firestore.DocumentReference) { + return { __firestoreDocumentReference: true, path: val.path }; + } + if (Array.isArray(val)) { + return val.map(serializeValue); + } + if (typeof val === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(val as Record)) { + out[k] = serializeValue(v); + } + return out; + } + return val; +} + +function parseArgs(): BackupOptions { + const args = process.argv.slice(2); + const options: BackupOptions = { + database: "development", + dryRun: false, + }; + + for (let i = 0; i < args.length; i += 1) { + switch (args[i]) { + case "--dry-run": + options.dryRun = true; + break; + case "--tenant": + options.tenant = args[++i]; + break; + case "--database": + options.database = (args[++i] || "development") as DatabaseEnv; + break; + case "--output-dir": + options.outputDir = args[++i]; + break; + case "--help": + console.log(` +Back up tenantSchema → tenantSchemaBackup (Firestore), or preview to disk. + +Usage: + npm run backup:tenant-schema -- [options] + +Options: + --database development | staging | production (default: development) + --tenant Back up only this document id (default: all documents) + --dry-run Do not write Firestore; export JSON under scripts/output/… + --output-dir With --dry-run only: output folder (default: scripts/output/tenant-schema-backup/) + --help Show this message + +Examples: + npm run backup:tenant-schema + npm run backup:tenant-schema -- --dry-run + npm run backup:tenant-schema -- --database staging --tenant mc +`); + process.exit(0); + break; + default: + break; + } + } + + if (!DATABASES[options.database]) { + console.error( + `Invalid --database "${options.database}". Use: ${Object.keys(DATABASES).join(", ")}` + ); + process.exit(1); + } + + return options; +} + +function initializeDb(databaseName: string): admin.firestore.Firestore { + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), + }), + }); + } + + const db = admin.firestore(); + if (databaseName !== "default") { + db.settings({ databaseId: databaseName }); + } + return db; +} + +function defaultOutputRoot(runStamp: string): string { + return path.join(__dirname, "output", "tenant-schema-backup", runStamp); +} + +async function backupToFirestore( + db: admin.firestore.Firestore, + col: admin.firestore.CollectionReference, + options: BackupOptions +): Promise { + if (options.tenant) { + const doc = await col.doc(options.tenant).get(); + if (!doc.exists) { + console.error(`No document "${options.tenant}" in ${TENANT_SCHEMA_COLLECTION}`); + process.exit(1); + } + const data = doc.data(); + if (!data) { + console.error(`Document "${options.tenant}" has no data`); + process.exit(1); + } + const { backupDocId, backupCollection } = await backupTenantSchemaDocument( + db, + options.tenant, + data, + BACKUP_TYPE_CLI + ); + console.log(`Backed up ${options.tenant} → ${backupCollection}/${backupDocId}`); + return; + } + + const result = await backupTenantSchemaCollection( + db, + options.database, + BACKUP_TYPE_CLI, + false + ); + if (!result.success) { + process.exit(1); + } +} + +async function backupToDiskDryRun( + db: admin.firestore.Firestore, + col: admin.firestore.CollectionReference, + options: BackupOptions, + firestoreDbId: string +): Promise { + let docs: admin.firestore.DocumentSnapshot[]; + if (options.tenant) { + const doc = await col.doc(options.tenant).get(); + if (!doc.exists) { + console.error(`No document "${options.tenant}" in ${TENANT_SCHEMA_COLLECTION}`); + process.exit(1); + } + docs = [doc]; + } else { + const snapshot = await col.get(); + if (snapshot.empty) { + console.log(`No documents in ${TENANT_SCHEMA_COLLECTION} (${options.database})`); + process.exit(0); + } + docs = snapshot.docs; + } + + const runStamp = formatBackupTimestamp(); + const outDir = options.outputDir + ? path.resolve(process.cwd(), options.outputDir) + : defaultOutputRoot(runStamp); + + console.log( + `[dry-run] Environment: ${options.database} (Firestore databaseId: ${firestoreDbId === "default" ? "(default)" : firestoreDbId})` + ); + console.log(`[dry-run] Would NOT write to ${TENANT_SCHEMA_BACKUP_COLLECTION}; writing local files under:`); + console.log(` ${outDir}`); + console.log(`[dry-run] Documents: ${docs.length}`); + + const manifest = { + mode: "dry-run-local-export" as const, + exportedAt: new Date().toISOString(), + environment: options.database, + firestoreDatabaseId: firestoreDbId, + collection: TENANT_SCHEMA_COLLECTION, + backupType: BACKUP_TYPE_CLI, + backupTimestamp: runStamp, + documents: [] as { tenantId: string; backupDocId: string; file: string }[], + }; + + fs.mkdirSync(outDir, { recursive: true }); + + for (const doc of docs) { + const backupDocId = createBackupDocId(doc.id, BACKUP_TYPE_CLI, runStamp); + const fileName = `${backupDocId}.json`; + manifest.documents.push({ + tenantId: doc.id, + backupDocId, + file: fileName, + }); + const payload = serializeValue(doc.data()); + const filePath = path.join(outDir, fileName); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + console.log(` wrote ${filePath}`); + } + + fs.writeFileSync( + path.join(outDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8" + ); + console.log(` manifest: ${path.join(outDir, "manifest.json")}`); +} + +async function main() { + const options = parseArgs(); + const firestoreDbId = DATABASES[options.database]; + const db = initializeDb(firestoreDbId); + const col = db.collection(TENANT_SCHEMA_COLLECTION); + + console.log( + `Environment: ${options.database} (Firestore databaseId: ${firestoreDbId === "default" ? "(default)" : firestoreDbId})` + ); + + if (!options.dryRun && options.outputDir) { + console.log("Note: --output-dir is ignored unless --dry-run is set."); + } + + if (options.dryRun) { + await backupToDiskDryRun(db, col, options, firestoreDbId); + } else { + console.log(`Writing backups to Firestore collection: ${TENANT_SCHEMA_BACKUP_COLLECTION}`); + await backupToFirestore(db, col, options); + } + + console.log("Done."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/booking-app/scripts/deploy-firestore-indexes.sh b/booking-app/scripts/deploy-firestore-indexes.sh new file mode 100755 index 000000000..ccd1daca8 --- /dev/null +++ b/booking-app/scripts/deploy-firestore-indexes.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Provision Firestore composite indexes for a single environment database. +# +# Single source of truth: firestore.indexes.json (the Firebase-native index +# format). To add or change an index, edit that file only — the deploy +# workflows do not need to change. +# +# We drive `gcloud firestore indexes composite create` rather than +# `firebase deploy --only firestore:indexes` because firebase-tools 15.x +# throws a TypeError on the multi-database `firestore` array in firebase.json, +# masking real failures. gcloud must already be authenticated by the caller. +# +# Usage: deploy-firestore-indexes.sh [indexes-file] +# Firestore database id (e.g. '(default)', 'booking-app-prod') +# GCP project id +# [indexes-file] defaults to firestore.indexes.json next to this script's dir +# +set -euo pipefail + +DB="${1:?database id required}" +PROJECT="${2:?project id required}" +INDEXES_FILE="${3:-$(dirname "$0")/../firestore.indexes.json}" + +command -v jq >/dev/null 2>&1 || { echo "jq is required but not installed" >&2; exit 1; } +[ -f "$INDEXES_FILE" ] || { echo "Indexes file not found: $INDEXES_FILE" >&2; exit 1; } + +echo "Provisioning Firestore indexes from '$INDEXES_FILE' into database '$DB' (project $PROJECT)" + +COUNT=$(jq '.indexes | length' "$INDEXES_FILE") +FAILED=0 + +for i in $(seq 0 $((COUNT - 1))); do + CG=$(jq -r ".indexes[$i].collectionGroup" "$INDEXES_FILE") + SCOPE=$(jq -r ".indexes[$i].queryScope // \"COLLECTION\"" "$INDEXES_FILE") + + ARGS=(--collection-group="$CG" --database="$DB" --project="$PROJECT") + if [ "$SCOPE" = "COLLECTION_GROUP" ]; then + ARGS+=(--query-scope=collection-group) + fi + + # One --field-config per field: ordered fields use order=asc/desc, array + # fields use array-config=contains. + while IFS= read -r field; do + path=$(jq -r '.fieldPath' <<<"$field") + order=$(jq -r '.order // empty' <<<"$field") + array=$(jq -r '.arrayConfig // empty' <<<"$field") + if [ -n "$order" ]; then + ord=$(echo "$order" | tr '[:upper:]' '[:lower:]') + ARGS+=("--field-config=field-path=$path,order=$ord") + elif [ -n "$array" ]; then + ARGS+=("--field-config=field-path=$path,array-config=contains") + else + echo " WARNING: field '$path' on $CG has neither order nor arrayConfig; skipping field" >&2 + fi + done < <(jq -c ".indexes[$i].fields[]" "$INDEXES_FILE") + + echo "Ensuring composite index on $CG ($SCOPE) in $DB..." + set +e + OUTPUT=$(gcloud firestore indexes composite create "${ARGS[@]}" --quiet 2>&1) + EXIT=$? + set -e + + if [ $EXIT -ne 0 ]; then + if echo "$OUTPUT" | grep -qi "ALREADY_EXISTS"; then + echo " already exists, skipping" + else + echo " FAILED to create index for $CG:" + echo "$OUTPUT" + FAILED=1 + fi + else + echo "$OUTPUT" + fi +done + +if [ "$FAILED" -ne 0 ]; then + echo "One or more indexes failed to create." >&2 + exit 1 +fi + +echo "All Firestore indexes ensured for database '$DB'." diff --git a/booking-app/scripts/migrateTenantResourceIds.js b/booking-app/scripts/migrateTenantResourceIds.js new file mode 100644 index 000000000..67fb0225f --- /dev/null +++ b/booking-app/scripts/migrateTenantResourceIds.js @@ -0,0 +1,248 @@ +//@ts-nocheck +require("dotenv").config({ path: ".env.local" }); +const admin = require("firebase-admin"); +const { + TENANT_SCHEMA_COLLECTION, + backupTenantSchemaDocument, +} = require("./tenantSchemaBackup"); + +const BACKUP_TYPE = "resource-id-string"; +const DATABASES = { + development: "default", + staging: "booking-app-staging", + production: "booking-app-prod", +}; + +const hasOwn = (value, key) => + Object.prototype.hasOwnProperty.call(value, key); + +const normalizeId = (id, path) => { + if ( + (typeof id !== "string" && typeof id !== "number") || + (typeof id === "number" && !Number.isFinite(id)) + ) { + throw new Error(`${path} must be a string or finite number`); + } + const normalizedId = String(id); + if (normalizedId.trim().length === 0) { + throw new Error(`${path} must be non-empty`); + } + if (normalizedId.includes(",")) { + throw new Error(`${path} must not contain commas`); + } + return normalizedId; +}; + +const migrateTenantSchemaData = (schema, tenantId = "unknown") => { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error(`tenantSchema/${tenantId} must be an object`); + } + if (!hasOwn(schema, "resources")) { + return { schema, changed: false }; + } + if (!Array.isArray(schema.resources)) { + throw new Error(`tenantSchema/${tenantId}.resources must be an array`); + } + + let changed = false; + const seenIds = new Set(); + const resources = schema.resources.map((resource, index) => { + const path = `tenantSchema/${tenantId}.resources[${index}]`; + if (resource === null || typeof resource !== "object" || Array.isArray(resource)) { + throw new Error(`${path} must be an object`); + } + + const hasResourceId = hasOwn(resource, "resourceId"); + const hasRoomId = hasOwn(resource, "roomId"); + let resourceId; + let resourceIdChanged = false; + + if (hasResourceId) { + resourceId = normalizeId(resource.resourceId, `${path}.resourceId`); + resourceIdChanged = resource.resourceId !== resourceId; + } + + if (hasRoomId) { + const legacyResourceId = normalizeId(resource.roomId, `${path}.roomId`); + if (hasResourceId && resourceId !== legacyResourceId) { + throw new Error( + `${path} has conflicting resourceId "${resourceId}" and roomId "${legacyResourceId}"` + ); + } + resourceId = legacyResourceId; + } + + if (!hasResourceId && !hasRoomId) { + throw new Error(`${path} must have resourceId or legacy roomId`); + } + if (seenIds.has(resourceId)) { + throw new Error( + `tenantSchema/${tenantId}.resources has duplicate resourceId "${resourceId}"` + ); + } + seenIds.add(resourceId); + + if (!hasRoomId && !resourceIdChanged) { + return resource; + } + + const { roomId: _legacyRoomId, ...withoutRoomId } = resource; + changed = true; + return { ...withoutRoomId, resourceId }; + }); + + return { + schema: changed ? { ...schema, resources } : schema, + changed, + }; +}; + +const getTenantDocuments = async (db, tenant) => { + if (tenant) { + const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).doc(tenant).get(); + if (!snapshot.exists) { + throw new Error(`No tenant schema found for "${tenant}"`); + } + return [{ id: tenant, data: () => snapshot.data() }]; + } + + const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).get(); + return snapshot.docs; +}; + +const migrateTenantResourceIds = async ( + db, + { dryRun = false, tenant } = {} +) => { + const documents = await getTenantDocuments(db, tenant); + + // Preflight every selected document so validation failures cannot cause a + // partially applied migration. + const migrations = documents.map((doc) => { + const existingSchema = doc.data(); + return { + tenantId: doc.id, + existingSchema, + ...migrateTenantSchemaData(existingSchema, doc.id), + }; + }); + const changedMigrations = migrations.filter((migration) => migration.changed); + + if (!dryRun) { + for (const migration of changedMigrations) { + await backupTenantSchemaDocument( + db, + migration.tenantId, + migration.existingSchema, + BACKUP_TYPE + ); + await db + .collection(TENANT_SCHEMA_COLLECTION) + .doc(migration.tenantId) + .set(migration.schema, { merge: false }); + } + } + + return { + dryRun, + scanned: migrations.length, + changed: changedMigrations.length, + unchanged: migrations.length - changedMigrations.length, + tenants: changedMigrations.map((migration) => migration.tenantId), + }; +}; + +const parseArgs = (args = process.argv.slice(2)) => { + const options = { dryRun: false, database: "development" }; + for (let index = 0; index < args.length; index++) { + switch (args[index]) { + case "--dry-run": + options.dryRun = true; + break; + case "--database": + options.database = args[++index]; + break; + case "--tenant": + options.tenant = args[++index]; + break; + case "--help": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${args[index]}`); + } + } + if (!DATABASES[options.database]) { + throw new Error( + `Invalid database "${options.database}". Expected one of: ${Object.keys(DATABASES).join(", ")}` + ); + } + return options; +}; + +const initializeDb = (databaseId) => { + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), + }), + }); + } + const db = admin.firestore(); + if (databaseId !== "default") { + db.settings({ databaseId }); + } + return db; +}; + +const main = async () => { + const options = parseArgs(); + if (options.help) { + console.log(`Usage: node scripts/migrateTenantResourceIds.js [options] + +Options: + --dry-run Validate and report without writing + --database development, staging, or production + --tenant Migrate one tenant instead of the whole collection + --help Show this help message`); + return; + } + + const result = await migrateTenantResourceIds( + initializeDb(DATABASES[options.database]), + options + ); + console.log( + `${options.dryRun ? "[DRY RUN] " : ""}Scanned ${result.scanned}; ` + + `${result.changed} ${options.dryRun ? "would change" : "changed"}; ` + + `${result.unchanged} unchanged.` + ); + if (result.tenants.length > 0) { + console.log(`Tenants: ${result.tenants.join(", ")}`); + } +}; + +if (require.main === module) { + main() + .catch((error) => { + console.error(`Migration failed: ${error.message}`); + process.exitCode = 1; + }) + .finally(async () => { + for (const app of admin.apps) { + if (app) { + await app.delete(); + } + } + }); +} + +module.exports = { + BACKUP_TYPE, + DATABASES, + migrateTenantSchemaData, + migrateTenantResourceIds, + parseArgs, +}; diff --git a/booking-app/scripts/migrateTenantSchemaFirestore.ts b/booking-app/scripts/migrateTenantSchemaFirestore.ts deleted file mode 100644 index 97b12f6e9..000000000 --- a/booking-app/scripts/migrateTenantSchemaFirestore.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Migrate Firestore tenantSchema documents to the canonical nested shape. - * - * Uses the same coercion rules as runtime (lib/tenant/coerceTenantSchema.ts): - * flat legacy fields become tenant, mappings, form, origins, emailNotifications, etc. - * - * Writes the full document with merge: false so legacy top-level keys are removed. - * By default only documents that need migration are touched; use --force-all to - * rewrite every document through coercion (for example after tweaking coerce logic). - */ - -require("dotenv").config({ path: ".env.local" }); -import * as admin from "firebase-admin"; -import * as fs from "fs"; -import * as path from "path"; -import { - coerceTenantSchema, - tenantSchemaFirestoreDocNeedsShapeMigration, -} from "../lib/tenant/coerceTenantSchema"; - -const { backupTenantSchemaDocument } = require("./tenantSchemaBackup"); - -const TENANT_SCHEMA_COLLECTION = "tenantSchema"; -const BACKUP_TYPE = "shape-migration"; - -const DATABASES = { - development: "default", - staging: "booking-app-staging", - production: "booking-app-prod", -} as const; - -type DatabaseEnv = keyof typeof DATABASES; - -interface MigrateOptions { - dryRun: boolean; - tenant?: string; - database: DatabaseEnv; - /** Rewrite every document even if it already looks nested and has no stale keys */ - forceAll: boolean; - /** Do not write to tenantSchemaBackup before replacing (not recommended) */ - skipBackup: boolean; -} - -function serializeForFirestore(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function sortKeysDeep(value: unknown): unknown { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(sortKeysDeep); - const o = value as Record; - const sorted: Record = {}; - for (const key of Object.keys(o).sort()) { - sorted[key] = sortKeysDeep(o[key]); - } - return sorted; -} - -function stableStringify(obj: unknown): string { - return JSON.stringify(sortKeysDeep(obj)); -} - -function parseArgs(): MigrateOptions { - const args = process.argv.slice(2); - const options: MigrateOptions = { - dryRun: false, - database: "development", - forceAll: false, - skipBackup: false, - }; - - // Consume the value following a flag, failing fast if it is missing or is - // actually another flag (e.g. `--tenant --database staging`). - const takeValue = (flag: string, i: number): string => { - const value = args[i + 1]; - if (value === undefined || value.startsWith("--")) { - console.error(`Missing value for ${flag}`); - process.exit(1); - } - return value; - }; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "--dry-run": - options.dryRun = true; - break; - case "--tenant": - options.tenant = takeValue("--tenant", i); - i++; - break; - case "--database": - options.database = takeValue("--database", i) as DatabaseEnv; - i++; - break; - case "--force-all": - options.forceAll = true; - break; - case "--skip-backup": - options.skipBackup = true; - break; - case "--help": - console.log(` -Usage: npm run migrate:tenant-schema -- [options] - (or: npx ts-node -r tsconfig-paths/register ... scripts/migrateTenantSchemaFirestore.ts) - -Rewrites tenantSchema/{tenantId} to the nested canonical shape (coerceTenantSchema). -Backs up the previous document to tenantSchemaBackup unless --dry-run or --skip-backup. - -Options: - --dry-run Preview only; writes JSON under scripts/output/ - --tenant Migrate a single document id (default: all docs in collection) - --database development | staging | production (default: development) - --force-all Rewrite every document even if no stale legacy keys remain - --skip-backup Do not copy current doc to tenantSchemaBackup before replace - --help Show this message - -Examples: - npm run migrate:tenant-schema:dry-run - npm run migrate:tenant-schema -- --tenant mc --database staging - npm run migrate:tenant-schema -- --database production --force-all -`); - process.exit(0); - break; - default: - console.error(`Unknown argument: ${args[i]}`); - process.exit(1); - } - } - - return options; -} - -function initializeDb(databaseName: string) { - if (!admin.apps.length) { - admin.initializeApp({ - credential: admin.credential.cert({ - projectId: process.env.FIREBASE_PROJECT_ID, - clientEmail: process.env.FIREBASE_CLIENT_EMAIL, - privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), - }), - }); - } - - const db = admin.firestore(); - if (databaseName !== "default") { - db.settings({ databaseId: databaseName }); - } - return db; -} - -function saveMigratedPreview( - tenantId: string, - database: string, - payload: Record, -): void { - const outputDir = path.join(__dirname, "output"); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - const filepath = path.join( - outputDir, - `migrate-tenant-schema-${tenantId}-${database}.json`, - ); - fs.writeFileSync(filepath, JSON.stringify(payload, null, 2), "utf-8"); - console.log(` 💾 Wrote preview: ${filepath}`); -} - -async function migrateOne( - db: admin.firestore.Firestore, - tenantId: string, - raw: Record, - options: MigrateOptions, -): Promise<"skipped" | "would" | "written"> { - const eligible = - options.forceAll || tenantSchemaFirestoreDocNeedsShapeMigration(raw); - if (!eligible) { - console.log(` ⏭️ Skip ${tenantId}: already canonical nested shape`); - return "skipped"; - } - - const migrated = coerceTenantSchema(raw, tenantId); - const payload = serializeForFirestore(migrated) as Record; - const unchanged = stableStringify(raw) === stableStringify(payload); - - if (unchanged) { - console.log( - ` ⏭️ Skip ${tenantId}: coerced JSON matches stored data (nothing to rewrite)`, - ); - return "skipped"; - } - - if (options.dryRun) { - console.log( - ` 🔍 [DRY RUN] Would replace ${tenantId} with coerced nested schema`, - ); - saveMigratedPreview(tenantId, options.database, payload); - return "would"; - } - - if (!options.skipBackup) { - const { backupDocId, backupCollection } = await backupTenantSchemaDocument( - db, - tenantId, - raw, - BACKUP_TYPE, - ); - console.log(` 📦 Backup ${backupCollection}/${backupDocId}`); - } - - await db - .collection(TENANT_SCHEMA_COLLECTION) - .doc(tenantId) - .set(payload, { merge: false }); - console.log(` ✅ Replaced ${tenantId} (merge: false)`); - return "written"; -} - -async function main() { - const options = parseArgs(); - - if (!DATABASES[options.database]) { - console.error(`Invalid --database: ${options.database}`); - console.error(`Valid: ${Object.keys(DATABASES).join(", ")}`); - process.exit(1); - } - - const dbName = DATABASES[options.database]; - const db = initializeDb(dbName); - - console.log("🚀 Tenant schema shape migration"); - console.log({ - dryRun: options.dryRun, - database: options.database, - firestoreDatabaseId: dbName, - tenant: options.tenant ?? "(all documents in tenantSchema)", - forceAll: options.forceAll, - skipBackup: options.skipBackup, - }); - - let docEntries: { id: string; raw: Record }[]; - if (options.tenant) { - const snap = await db - .collection(TENANT_SCHEMA_COLLECTION) - .doc(options.tenant) - .get(); - if (!snap.exists) { - console.error(`No document tenantSchema/${options.tenant}`); - process.exit(1); - } - docEntries = [ - { id: snap.id, raw: snap.data() as Record }, - ]; - } else { - const snap = await db.collection(TENANT_SCHEMA_COLLECTION).get(); - docEntries = snap.docs.map((d) => ({ - id: d.id, - raw: d.data() as Record, - })); - } - - const counts = { skipped: 0, would: 0, written: 0 }; - - for (const { id, raw } of docEntries) { - console.log(`\n📄 ${id}`); - const result = await migrateOne(db, id, raw, options); - counts[result]++; - } - - console.log("\n📊 Summary:", counts); - if (options.dryRun) { - console.log("\n💡 Run without --dry-run to apply (after reviewing scripts/output/)."); - } -} - -if (require.main === module) { - main() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(async () => { - for (const app of admin.apps) { - if (app) await app.delete(); - } - }); -} diff --git a/booking-app/scripts/schemaDefaults.ts b/booking-app/scripts/schemaDefaults.ts index f3d0ede23..ea288c2a4 100644 --- a/booking-app/scripts/schemaDefaults.ts +++ b/booking-app/scripts/schemaDefaults.ts @@ -92,19 +92,9 @@ export function mergeSchemaDefaults( // Start with default schema const defaultSchema: SchemaContextType = generateDefaultTenantSchema(tenant); - // Coerce FIRST, then add defaults. - // - // Order matters: `deepAddDefaults` injects empty nested defaults - // (`emailNotifications`, `attestations`, `resource.training`, and the nested - // `tenant`/`mappings`/`form` objects). If we did that before coercion, a - // legacy document would suddenly look like the new nested shape, so - // `coerceTenantSchema` would take its new-shape branch and let those empty - // defaults shadow the real legacy values (`emailMessages`, `agreements`, - // `needsSafetyTraining`/training URLs) — silently blanking them. - // - // Coercing the raw document first maps every legacy field into the canonical - // nested shape; the subsequent add-only merge then only fills genuinely - // missing keys without overwriting anything coercion populated. + // Coerce FIRST, then add defaults. Coercion merges the stored document over + // the tenant defaults; the subsequent add-only merge then only fills + // genuinely missing keys without overwriting anything coercion populated. const coerced = coerceTenantSchema(existingSchema, tenant); // Add-only merge: diff --git a/booking-app/scripts/tenantSchemaBackup.js b/booking-app/scripts/tenantSchemaBackup.js index 6f58b7355..13b37717c 100644 --- a/booking-app/scripts/tenantSchemaBackup.js +++ b/booking-app/scripts/tenantSchemaBackup.js @@ -1,12 +1,15 @@ const TENANT_SCHEMA_COLLECTION = "tenantSchema"; const TENANT_SCHEMA_BACKUP_COLLECTION = "tenantSchemaBackup"; -const createBackupDocId = (tenantId, backupType) => { - const timestamp = new Date() +const formatBackupTimestamp = () => + new Date() .toISOString() .replace(/[:.]/g, "-") .replace("T", "_") .replace("Z", ""); + +/** @param {string} [timestamp] Same stamp for all docs in a batch (e.g. disk export); omit for a new stamp per call. */ +const createBackupDocId = (tenantId, backupType, timestamp = formatBackupTimestamp()) => { return `${tenantId}-backup-${backupType}-${timestamp}`; }; @@ -102,6 +105,7 @@ const backupTenantSchemaCollection = async ( module.exports = { TENANT_SCHEMA_COLLECTION, TENANT_SCHEMA_BACKUP_COLLECTION, + formatBackupTimestamp, createBackupDocId, backupTenantSchemaDocument, backupTenantSchemaCollection, diff --git a/booking-app/tailwind.config.ts b/booking-app/tailwind.config.ts deleted file mode 100644 index 7e4bd91a0..000000000 --- a/booking-app/tailwind.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - content: [ - "./pages/**/*.{js,ts,jsx,tsx,mdx}", - "./components/**/*.{js,ts,jsx,tsx,mdx}", - "./app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", - }, - }, - }, - plugins: [], -}; -export default config; diff --git a/booking-app/tests/e2e/request-limits.e2e.test.ts b/booking-app/tests/e2e/request-limits.e2e.test.ts new file mode 100644 index 000000000..3828a61ab --- /dev/null +++ b/booking-app/tests/e2e/request-limits.e2e.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; +import { registerBookingMocks } from "./helpers/mock-routes"; +import { selectDropdown, selectTimeSlot } from "./helpers/test-utils"; + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000"; + +test.describe("Request Limits", () => { + test.beforeEach(async ({ page }) => { + await registerBookingMocks(page); + }); + + test("blocks Next on /selectRoom and shows the server's 429 message when the limit is reached", async ({ + page, + }) => { + const limitMessage = + 'Request limit reached for "Lecture Hall 202" (perDay). Limit: 1.'; + + let requestLimitCalled = false; + await page.route("**/api/bookings/request-limits", async (route) => { + requestLimitCalled = true; + return route.fulfill({ + status: 429, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: false, error: limitMessage }), + }); + }); + + await page.goto(`${BASE_URL}/mc`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + const requestBtn = page.getByRole("button", { + name: /Request a Reservation/i, + }); + await requestBtn.waitFor({ state: "visible", timeout: 15000 }); + await requestBtn.click(); + + await page.waitForURL("**/mc/book", { timeout: 15000 }); + await page.waitForLoadState("networkidle"); + const acceptBtn = page.getByRole("button", { name: /^I accept$/i }); + await acceptBtn.waitFor({ state: "visible", timeout: 10000 }); + await acceptBtn.click(); + + await page.waitForURL("**/mc/book/role", { timeout: 15000 }); + await page.waitForLoadState("networkidle"); + await selectDropdown(page, "school-select", 0); + await selectDropdown(page, "department-select", 0); + await selectDropdown(page, "role-select", 1); // Faculty + await page.getByRole("button", { name: "Next", exact: true }).click(); + + await page.waitForURL("**/mc/book/selectRoom", { timeout: 15000 }); + await selectTimeSlot(page, "202"); + + // Wait for the /api/bookings/request-limits call to land. The hook fires + // once both a room and a time slot are selected. + await expect + .poll(() => requestLimitCalled, { timeout: 15000 }) + .toBe(true); + + const alert = page.getByRole("alert").filter({ hasText: limitMessage }); + await expect(alert).toBeVisible({ timeout: 10000 }); + + const nextBtn = page.getByRole("button", { name: "Next", exact: true }); + await expect(nextBtn).toBeDisabled(); + }); +}); diff --git a/booking-app/tests/setup.ts b/booking-app/tests/setup.ts index 9bb993a61..3e4f6e38c 100644 --- a/booking-app/tests/setup.ts +++ b/booking-app/tests/setup.ts @@ -103,16 +103,22 @@ Object.defineProperty(window, "matchMedia", { })), }); -// Mock IntersectionObserver -global.IntersectionObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock IntersectionObserver — must be a constructable class for components that use `new IntersectionObserver()` +class IntersectionObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor(_callback: IntersectionObserverCallback) {} +} +global.IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver; -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock ResizeObserver — must be a constructable class (MUI Tabs uses `new ResizeObserver()`) +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor(_callback: ResizeObserverCallback) {} +} +global.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; diff --git a/booking-app/tests/unit/BookingStatusBar.unit.test.tsx b/booking-app/tests/unit/BookingStatusBar.unit.test.tsx index 228b47cbc..dba4ae6da 100644 --- a/booking-app/tests/unit/BookingStatusBar.unit.test.tsx +++ b/booking-app/tests/unit/BookingStatusBar.unit.test.tsx @@ -30,6 +30,13 @@ vi.mock( }) ); +vi.mock( + "@/components/src/client/routes/booking/hooks/useCheckRequestLimits", + () => ({ + default: () => ({ requestLimitError: null, requestLimitChecking: false }), + }) +); + // Mock SchemaProvider (preserve defaultSafetyTrainingInfoUrl for BookingStatusBar) const mockUseTenantSchema = vi.fn(); vi.mock("@/components/src/client/routes/components/SchemaProvider", async (importOriginal) => { diff --git a/booking-app/tests/unit/api-approve.unit.test.ts b/booking-app/tests/unit/api-approve.unit.test.ts index bc291f779..0b3aebdb8 100644 --- a/booking-app/tests/unit/api-approve.unit.test.ts +++ b/booking-app/tests/unit/api-approve.unit.test.ts @@ -19,6 +19,16 @@ vi.mock("@/components/src/server/admin", () => ({ vi.mock("@/lib/firebase/server/adminDb", () => ({ serverGetDataByCalendarEventId: vi.fn(), serverFetchAllDataFromCollection: vi.fn(), + serverGetFinalApproverEmail: vi.fn(), + serverListResourceApproversByEmail: vi.fn(), +})); + +vi.mock("@/lib/api/requireSession", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/api/authz", () => ({ + resolveCallerRole: vi.fn(), })); vi.mock("@/components/src/server/emails", () => ({ @@ -34,6 +44,7 @@ vi.mock("@/components/src/utils/tenantUtils", () => ({ })); import { POST } from "@/app/api/approve/route"; import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; +import { PagePermission } from "@/components/src/types"; import { finalApprove, serverApproveBooking, @@ -43,8 +54,12 @@ import { import { executeXStateTransition } from "@/lib/stateMachines/xstateUtilsV5"; import { serverFetchAllDataFromCollection, + serverGetFinalApproverEmail, serverGetDataByCalendarEventId, + serverListResourceApproversByEmail, } from "@/lib/firebase/server/adminDb"; +import { resolveCallerRole } from "@/lib/api/authz"; +import { requireSession } from "@/lib/api/requireSession"; import { getMediaCommonsServices, isMediaCommons, @@ -74,12 +89,19 @@ const mockServerGetDataByCalendarEventId = vi.mocked( const mockServerFetchAllDataFromCollection = vi.mocked( serverFetchAllDataFromCollection, ); +const mockServerGetFinalApproverEmail = vi.mocked(serverGetFinalApproverEmail); +const mockServerListResourceApproversByEmail = vi.mocked( + serverListResourceApproversByEmail, +); +const mockRequireSession = vi.mocked(requireSession); +const mockResolveCallerRole = vi.mocked(resolveCallerRole); const mockIsMediaCommons = vi.mocked(isMediaCommons); const mockGetMediaCommonsServices = vi.mocked(getMediaCommonsServices); const bookingId = "calendar-1"; -const approverEmail = "approver@nyu.edu"; +const sessionEmail = "session-approver@nyu.edu"; +const bodyEmail = "spoofed-approver@nyu.edu"; const parseJson = async (response: Response) => { const data = await response.json(); @@ -89,6 +111,13 @@ const parseJson = async (response: Response) => { describe("POST /api/approve", () => { beforeEach(() => { vi.clearAllMocks(); + mockRequireSession.mockResolvedValue({ + email: sessionEmail, + netId: "session-approver", + } as any); + mockResolveCallerRole.mockResolvedValue(PagePermission.ADMIN); + mockServerGetFinalApproverEmail.mockResolvedValue(null); + mockServerListResourceApproversByEmail.mockResolvedValue([]); mockIsMediaCommons.mockReturnValue(false); mockGetMediaCommonsServices.mockReturnValue({ staff: false, @@ -116,26 +145,203 @@ describe("POST /api/approve", () => { mockFetch.mockResolvedValue({ ok: true }); }); + it("returns 401 when unauthenticated", async () => { + mockRequireSession.mockResolvedValue(null); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 401, + data: { error: "Unauthorized" }, + }); + expect(mockServerGetDataByCalendarEventId).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("returns 400 for an invalid tenant", async () => { + const response = await POST( + createRequest( + { id: bookingId, email: bodyEmail }, + { "x-tenant": "invalid-tenant" }, + ) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 400, + data: { error: "Invalid tenant" }, + }); + expect(mockServerGetDataByCalendarEventId).not.toHaveBeenCalled(); + }); + + it("returns 404 when the booking does not exist", async () => { + mockServerGetDataByCalendarEventId.mockResolvedValue(null); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 404, + data: { error: "Booking not found" }, + }); + expect(mockResolveCallerRole).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller is not authorized", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 403, + data: { error: "You are not authorized to approve this booking" }, + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("allows a liaison to perform the first approval using the session email", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.LIAISON); + mockExecute.mockResolvedValue({ success: true, newState: "Pre-approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(mockResolveCallerRole).toHaveBeenCalledWith( + { email: sessionEmail, netId: "session-approver" }, + DEFAULT_TENANT, + ); + expect(mockExecute).toHaveBeenCalledWith( + bookingId, + "approve", + DEFAULT_TENANT, + sessionEmail, + ); + expect(mockServerFirstApproveOnly).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(response.status).toBe(200); + }); + + it("allows a resource approver to final approve only when assigned to every booking resource", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1, room-2", + } as any); + mockServerListResourceApproversByEmail.mockResolvedValue([ + { resourceId: "room-1", email: sessionEmail }, + { resourceId: "room-2", email: sessionEmail.toUpperCase() }, + ] as any); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(mockServerListResourceApproversByEmail).toHaveBeenCalledWith( + sessionEmail, + DEFAULT_TENANT, + ); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(response.status).toBe(200); + }); + + it.each([ + ["unrelated", [{ resourceId: "room-3", email: sessionEmail }]], + ["partially assigned", [{ resourceId: "room-1", email: sessionEmail }]], + ])("denies a resource approver who is %s", async (_label, assignments) => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1, room-2", + } as any); + mockServerListResourceApproversByEmail.mockResolvedValue(assignments as any); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(403); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("allows the tenant final approver to final approve", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1", + } as any); + mockServerGetFinalApproverEmail.mockResolvedValue( + ` ${sessionEmail.toUpperCase()} `, + ); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(200); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(mockServerListResourceApproversByEmail).not.toHaveBeenCalled(); + }); + + it.each([PagePermission.ADMIN, PagePermission.SUPER_ADMIN])( + "allows a %s to approve", + async (role) => { + mockResolveCallerRole.mockResolvedValue(role); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(200); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + }, + ); + it("falls back to serverApproveBooking when XState transition fails", async () => { mockExecute.mockResolvedValue({ success: false, error: "state error" }); const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, - { "x-tenant": "tenant-a" }, + { id: bookingId, email: bodyEmail }, + { "x-tenant": "itp" }, ) as any, ); expect(mockExecute).toHaveBeenCalledWith( bookingId, "approve", - "tenant-a", - approverEmail, + "itp", + sessionEmail, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, - "tenant-a", + sessionEmail, + "itp", ); expect(mockFinalApprove).not.toHaveBeenCalled(); expect(mockServerFirstApproveOnly).not.toHaveBeenCalled(); @@ -168,7 +374,7 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); @@ -201,14 +407,14 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, "mc", ); expect(response.status).toBe(200); @@ -240,14 +446,14 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, "mc", ); expect(response.status).toBe(200); @@ -257,18 +463,18 @@ describe("POST /api/approve", () => { mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); expect(mockExecute).toHaveBeenCalledWith( bookingId, "approve", DEFAULT_TENANT, - approverEmail, + sessionEmail, ); expect(mockFinalApprove).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, DEFAULT_TENANT, ); expect(mockServerApproveBooking).not.toHaveBeenCalled(); @@ -283,12 +489,12 @@ describe("POST /api/approve", () => { mockExecute.mockResolvedValue({ success: true, newState: "Pre-approved" }); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); expect(mockServerFirstApproveOnly).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, DEFAULT_TENANT, ); expect(mockFinalApprove).not.toHaveBeenCalled(); @@ -307,8 +513,8 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, - { "x-tenant": "tenant-b" }, + { id: bookingId, email: bodyEmail }, + { "x-tenant": "itp" }, ) as any, ); @@ -328,7 +534,7 @@ describe("POST /api/approve", () => { mockServerApproveBooking.mockRejectedValue(new Error("Fallback failed")); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); await expect(parseJson(response)).resolves.toEqual({ diff --git a/booking-app/tests/unit/api-safety-training-form.unit.test.ts b/booking-app/tests/unit/api-safety-training-form.unit.test.ts index 8909d1bd3..7721829c5 100644 --- a/booking-app/tests/unit/api-safety-training-form.unit.test.ts +++ b/booking-app/tests/unit/api-safety-training-form.unit.test.ts @@ -81,7 +81,7 @@ describe("GET /api/safety_training_form", () => { it("returns 404 when safety training form is not configured", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - // No safetyTrainingGoogleFormId + // No training form configured }); const request = createRequest({ "x-tenant": "test-tenant" }); @@ -98,7 +98,7 @@ describe("GET /api/safety_training_form", () => { it("returns emails from form responses successfully", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -133,7 +133,7 @@ describe("GET /api/safety_training_form", () => { it("filters out invalid emails from responses", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -163,7 +163,7 @@ describe("GET /api/safety_training_form", () => { it("returns empty array when no form responses exist", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -184,7 +184,7 @@ describe("GET /api/safety_training_form", () => { it("returns empty array when responses array is empty", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -205,7 +205,7 @@ describe("GET /api/safety_training_form", () => { it("includes resourceId in logging when provided", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -238,7 +238,7 @@ describe("GET /api/safety_training_form", () => { it("sets correct cache control headers", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockResolvedValue({ @@ -261,7 +261,7 @@ describe("GET /api/safety_training_form", () => { it("returns 401 for invalid_grant authentication errors", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockRejectedValue( @@ -282,7 +282,7 @@ describe("GET /api/safety_training_form", () => { it("returns 404 for Form not found errors", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "invalid-form-id", + training: { formId: "invalid-form-id" }, }); mockListResponses.mockRejectedValue(new Error("Form not found")); @@ -301,7 +301,7 @@ describe("GET /api/safety_training_form", () => { it("returns 403 for permission_denied errors", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockRejectedValue( @@ -326,7 +326,7 @@ describe("GET /api/safety_training_form", () => { it("returns 403 for Insufficient Permission errors", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockRejectedValue( @@ -351,7 +351,7 @@ describe("GET /api/safety_training_form", () => { it("returns 500 for generic errors with details", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); const error = new Error("Network error") as any; @@ -374,7 +374,7 @@ describe("GET /api/safety_training_form", () => { it("returns UNKNOWN code for errors without code property", async () => { mockServerGetDocumentById.mockResolvedValue({ name: "Test Tenant", - safetyTrainingGoogleFormId: "form-123", + training: { formId: "form-123" }, }); mockListResponses.mockRejectedValue(new Error("Something went wrong")); diff --git a/booking-app/tests/unit/api-tenant-schema.unit.test.ts b/booking-app/tests/unit/api-tenant-schema.unit.test.ts index 047182826..555eab317 100644 --- a/booking-app/tests/unit/api-tenant-schema.unit.test.ts +++ b/booking-app/tests/unit/api-tenant-schema.unit.test.ts @@ -63,10 +63,10 @@ const parseJson = async (response: Response) => { }; const mockSchema = { - tenant: "mc", - name: "Media Commons", - showEquipment: true, - resources: [{ name: "Room 1", roomId: 1 }], + tenantId: "mc", + tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, + form: { services: { showEquipment: true } }, + resources: [{ name: "Room 1", resourceId: "1" }], }; describe("GET /api/tenantSchema/[tenant]", () => { @@ -83,6 +83,8 @@ describe("GET /api/tenantSchema/[tenant]", () => { expect(status).toBe(200); expect(data.tenant.name).toBe("Media Commons"); expect(data.resources).toHaveLength(1); + expect(data.resources[0].resourceId).toBe("1"); + expect(data.resources[0]).not.toHaveProperty("roomId"); }); it("returns 404 when schema not found", async () => { diff --git a/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts b/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts index 38a1eff88..922d20088 100644 --- a/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts +++ b/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts @@ -155,14 +155,21 @@ describe("Edit resubmission resets declined services", () => { const updatedData = updateArgs[2]; expect(updatedData.calendarEventId).toBe("new-cal-456"); + expect(updatedData.staffServiceApproved).toBeUndefined(); - // Critical: reset service decisions - expect(updatedData.staffServiceApproved).toBeNull(); - expect(updatedData.equipmentServiceApproved).toBeNull(); - expect(updatedData.cateringServiceApproved).toBeNull(); - expect(updatedData.cleaningServiceApproved).toBeNull(); - expect(updatedData.securityServiceApproved).toBeNull(); - expect(updatedData.setupServiceApproved).toBeNull(); + expect(mockServerDeleteFieldsByCalendarEventId).toHaveBeenCalledWith( + "bookings", + "new-cal-456", + [ + "staffServiceApproved", + "equipmentServiceApproved", + "cateringServiceApproved", + "cleaningServiceApproved", + "securityServiceApproved", + "setupServiceApproved", + ], + "mc", + ); // Still runs XState edit transition for declined bookings expect(mockCallXStateTransitionAPI).toHaveBeenCalledWith( diff --git a/booking-app/tests/unit/coerce-tenant-schema-migration.unit.test.ts b/booking-app/tests/unit/coerce-tenant-schema-migration.unit.test.ts deleted file mode 100644 index 037c7e548..000000000 --- a/booking-app/tests/unit/coerce-tenant-schema-migration.unit.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isNestedTenantSchemaDocument, - tenantSchemaFirestoreDocNeedsShapeMigration, -} from "@/lib/tenant/coerceTenantSchema"; - -describe("tenantSchemaFirestoreDocNeedsShapeMigration", () => { - it("returns true for flat legacy top-level fields", () => { - expect( - tenantSchemaFirestoreDocNeedsShapeMigration({ - name: "MC", - emailMessages: { requestConfirmation: "t1" }, - resources: [], - }), - ).toBe(true); - }); - - it("returns true when tenant is a legacy string slug", () => { - expect( - tenantSchemaFirestoreDocNeedsShapeMigration({ - tenant: "mc", - resources: [], - }), - ).toBe(true); - }); - - it("returns true when nested shape still has stale legacy keys", () => { - expect( - tenantSchemaFirestoreDocNeedsShapeMigration({ - tenantId: "mc", - tenant: { name: "x", logo: "", nameForPolicy: "", contextLabels: {} }, - mappings: { program: {}, role: {}, school: {} }, - form: { showBookingType: true, showNNumber: true, showSponsor: true, services: {} }, - emailMessages: { requestConfirmation: "old" }, - emailNotifications: { requestedUser: "new" }, - } as Record), - ).toBe(true); - }); - - it("returns true when nested doc still has legacy fields inside resources", () => { - // Top-level is fully nested/canonical, but a resource entry still carries - // legacy training fields — those need canonicalizing too. - expect( - tenantSchemaFirestoreDocNeedsShapeMigration({ - tenantId: "mc", - tenant: { name: "x", logo: "", nameForPolicy: "", contextLabels: {} }, - mappings: { program: {}, role: {}, school: {} }, - form: { showBookingType: true, showNNumber: true, showSponsor: true, services: {} }, - origins: { VIP: false, walkIn: false }, - emailNotifications: { requestedUser: "" }, - resources: [ - { name: "R1", roomId: 1, needsSafetyTraining: true }, - ], - } as Record), - ).toBe(true); - }); - - it("returns false for minimal nested canonical doc", () => { - const doc = { - tenantId: "mc", - tenant: { - name: "Media Commons", - logo: "", - nameForPolicy: "", - contextLabels: { - user: "User", - worker: "PA", - reviewer: "Liaison", - services: "Services", - admin: "Admin", - }, - }, - mappings: { program: {}, role: {}, school: {} }, - form: { - showBookingType: true, - showNNumber: true, - showSponsor: true, - services: { - showCatering: true, - showEquipment: true, - showSecurity: true, - showSetup: true, - showStaffing: true, - }, - }, - origins: { VIP: false, walkIn: false }, - emailNotifications: { requestedUser: "" }, - }; - expect(tenantSchemaFirestoreDocNeedsShapeMigration(doc)).toBe(false); - expect(isNestedTenantSchemaDocument(doc)).toBe(true); - }); -}); diff --git a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts index e697cd634..300a48c35 100644 --- a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts +++ b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts @@ -9,40 +9,93 @@ describe("coerceTenantSchema — timeSensitiveRequestWarning", () => { policyLink: "https://policy.example", }; - it("picks up a top-level warning from a fully-legacy document", () => { - const legacy: any = { - tenant: "mc", - name: "Media Commons", - timeSensitiveRequestWarning: warning, - }; - const c = coerceTenantSchema(legacy, "mc"); - expect(c.calendarConfig?.timeSensitiveRequestWarning).toMatchObject(warning); - }); + const canonicalBase = { + tenantId: "mc", + tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, + mappings: { program: {}, role: {}, school: {} }, + form: { services: {} }, + }; - it("picks up a top-level warning from a partially-migrated (new-shape) document", () => { - // Nested tenant/mappings/form => coerce takes the new-shape branch, but the - // warning is still stored at the legacy top level. It must not be dropped. - const partial: any = { - tenantId: "mc", - tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, - mappings: { program: {}, role: {}, school: {} }, - form: { services: {} }, - timeSensitiveRequestWarning: warning, + it("keeps a nested calendarConfig warning from a canonical document", () => { + const doc: any = { + ...canonicalBase, + calendarConfig: { timeSensitiveRequestWarning: warning }, }; - const c = coerceTenantSchema(partial, "mc"); + const c = coerceTenantSchema(doc, "mc"); expect(c.calendarConfig?.timeSensitiveRequestWarning).toMatchObject(warning); }); - it("prefers the nested warning over a stale top-level one", () => { - const both: any = { - tenantId: "mc", - tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, - mappings: { program: {}, role: {}, school: {} }, - form: { services: {} }, - timeSensitiveRequestWarning: { ...warning, hours: 12 }, + it("merges a partial nested warning over the defaults", () => { + const doc: any = { + ...canonicalBase, calendarConfig: { timeSensitiveRequestWarning: { hours: 99 } }, }; - const c = coerceTenantSchema(both, "mc"); + const c = coerceTenantSchema(doc, "mc"); expect(c.calendarConfig?.timeSensitiveRequestWarning?.hours).toBe(99); }); }); + +describe("coerceTenantSchema — resources", () => { + it.each([ + ["number", 202, "202"], + ["string", "studio-a", "studio-a"], + ])("coerces a legacy %s roomId to resourceId", (_, roomId, resourceId) => { + const resource = { + roomId, + name: "Studio", + capacity: 12, + customField: { keep: true }, + }; + + const coerced = coerceTenantSchema({ resources: [resource] }, "mc"); + + expect(coerced.resources[0]).toEqual({ + resourceId, + name: "Studio", + capacity: 12, + customField: { keep: true }, + }); + expect(coerced.resources[0]).not.toHaveProperty("roomId"); + }); + + it("keeps a canonical resourceId and removes a matching legacy roomId", () => { + const coerced = coerceTenantSchema( + { + resources: [ + { + resourceId: "canonical-id", + roomId: "canonical-id", + name: "Canonical resource", + }, + ], + }, + "mc", + ); + + expect(coerced.resources[0].resourceId).toBe("canonical-id"); + expect(coerced.resources[0]).not.toHaveProperty("roomId"); + }); + + it("rejects conflicting legacy and canonical IDs", () => { + expect(() => + coerceTenantSchema( + { resources: [{ resourceId: "studio-a", roomId: 202 }] }, + "mc", + ), + ).toThrow("conflicting resourceId and roomId"); + }); + + it("rejects duplicate canonical IDs", () => { + expect(() => + coerceTenantSchema( + { + resources: [ + { resourceId: "studio-a" }, + { roomId: "studio-a" }, + ], + }, + "mc", + ), + ).toThrow('duplicate resourceId "studio-a"'); + }); +}); diff --git a/booking-app/tests/unit/date-utils.unit.test.tsx b/booking-app/tests/unit/date-utils.unit.test.tsx index 88dfd113a..e61ae09f2 100644 --- a/booking-app/tests/unit/date-utils.unit.test.tsx +++ b/booking-app/tests/unit/date-utils.unit.test.tsx @@ -1,6 +1,37 @@ import { describe, expect, it } from "vitest"; +import { + formatTimeAmPm, + toBookingCalendarStr, +} from "@/components/src/client/utils/date"; describe("Date Utils", () => { + describe("toBookingCalendarStr", () => { + it("formats 9:00 AM Eastern as local calendar string without UTC suffix", () => { + // 9:00 AM EDT on 2026-04-07 = 13:00 UTC + const start = new Date("2026-04-07T13:00:00.000Z"); + expect(toBookingCalendarStr(start)).toBe("2026-04-07T09:00:00"); + }); + + it("formats 9:00 PM Eastern as local calendar string", () => { + // 9:00 PM EDT on 2026-04-07 = 01:00 UTC next day + const end = new Date("2026-04-08T01:00:00.000Z"); + expect(toBookingCalendarStr(end)).toBe("2026-04-07T21:00:00"); + }); + + it("does not produce UTC ISO strings that shift start time on resize", () => { + const start = new Date("2026-04-07T13:00:00.000Z"); + const end = new Date("2026-04-08T01:00:00.000Z"); + + const startStr = toBookingCalendarStr(start); + const endStr = toBookingCalendarStr(end); + + expect(startStr).not.toContain("Z"); + expect(endStr).not.toContain("Z"); + expect(formatTimeAmPm(start)).toBe("9:00 AM"); + expect(formatTimeAmPm(end)).toBe("9:00 PM"); + }); + }); + describe("formatBookingTime", () => { const formatBookingTime = (dateStr: string) => { const date = new Date(dateStr); diff --git a/booking-app/tests/unit/firestoreAuthz.unit.test.ts b/booking-app/tests/unit/firestoreAuthz.unit.test.ts index 669fe48f2..34d01dfb9 100644 --- a/booking-app/tests/unit/firestoreAuthz.unit.test.ts +++ b/booking-app/tests/unit/firestoreAuthz.unit.test.ts @@ -60,6 +60,16 @@ describe("authorizeWrite", () => { expect(decision.ok).toBe(true); }); + it("allows admin write to usersResourceApprovers", async () => { + usersRightsSnap.mockReturnValue([{ isAdmin: true }]); + const decision = await authorizeWrite( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(true); + }); + it("blocks admin from writing usersSuperAdmin (super-only)", async () => { usersRightsSnap.mockReturnValue([{ isAdmin: true }]); const decision = await authorizeWrite(session, "mc", "usersSuperAdmin"); @@ -101,6 +111,25 @@ describe("authorizeRead", () => { expect(decision.ok).toBe(true); }); + it("blocks non-admin read of usersResourceApprovers", async () => { + const decision = await authorizeRead( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(false); + }); + + it("allows admin read of usersResourceApprovers", async () => { + usersRightsSnap.mockReturnValue([{ isAdmin: true }]); + const decision = await authorizeRead( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(true); + }); + it("blocks non-admin read of preBanLogs", async () => { const decision = await authorizeRead(session, "mc", "preBanLogs"); expect(decision.ok).toBe(false); diff --git a/booking-app/tests/unit/form-context-field-visibility.unit.test.tsx b/booking-app/tests/unit/form-context-field-visibility.unit.test.tsx index 079118bf4..475900ff3 100644 --- a/booking-app/tests/unit/form-context-field-visibility.unit.test.tsx +++ b/booking-app/tests/unit/form-context-field-visibility.unit.test.tsx @@ -94,47 +94,36 @@ describe("FormInput - Field Visibility by Form Context", () => { }); const rawMockTenantSchema: Record = { - tenant: "media-commons", - name: "Media Commons", - logo: "", - nameForPolicy: "Media Commons", + tenantId: "media-commons", + tenant: { + name: "Media Commons", + logo: "", + nameForPolicy: "Media Commons", + }, policy: "", roles: ["Student", "Faculty", "Staff"], - showNNumber: true, - showSponsor: true, - showSetup: true, - showEquipment: true, - showStaffing: true, - showCatering: true, - showHireSecurity: true, - showBookingTypes: true, - agreements: [ + mappings: { program: {}, role: {}, school: {} }, + form: { + showNNumber: true, + showSponsor: true, + showBookingType: true, + services: { + showSetup: true, + showEquipment: true, + showStaffing: true, + showCatering: true, + showSecurity: true, + }, + }, + attestations: [ { id: "agreement1", html: "

I agree to the terms and conditions

", }, ], resources: [], - supportVIP: true, - supportWalkIn: true, + origins: { VIP: true, walkIn: true }, resourceName: "Room", - programMapping: {}, - roleMapping: {}, - emailMessages: { - requestConfirmation: "", - firstApprovalRequest: "", - secondApprovalRequest: "", - walkInConfirmation: "", - vipConfirmation: "", - checkoutConfirmation: "", - checkinConfirmation: "", - declined: "", - canceled: "", - lateCancel: "", - noShow: "", - closed: "", - approvalNotice: "", - }, }; const mockTenantSchema = coerceTenantSchema( @@ -496,7 +485,10 @@ describe("FormInput - Field Visibility by Form Context", () => { describe("Field Visibility Based on Tenant Schema", () => { it("hides N-Number when showNNumber is false", () => { const schemaWithoutNNumber = coerceTenantSchema( - { ...rawMockTenantSchema, showNNumber: false }, + { + ...rawMockTenantSchema, + form: { ...(rawMockTenantSchema.form as object), showNNumber: false }, + }, "media-commons", ); @@ -520,7 +512,10 @@ describe("FormInput - Field Visibility by Form Context", () => { it("hides Net ID when showSponsor is false", () => { const schemaWithoutSponsor = coerceTenantSchema( - { ...rawMockTenantSchema, showSponsor: false }, + { + ...rawMockTenantSchema, + form: { ...(rawMockTenantSchema.form as object), showSponsor: false }, + }, "media-commons", ); @@ -544,7 +539,16 @@ describe("FormInput - Field Visibility by Form Context", () => { it("hides Setup service when showSetup is false", () => { const schemaWithoutSetup = coerceTenantSchema( - { ...rawMockTenantSchema, showSetup: false }, + { + ...rawMockTenantSchema, + form: { + ...(rawMockTenantSchema.form as { services: object }), + services: { + ...(rawMockTenantSchema.form as { services: object }).services, + showSetup: false, + }, + }, + }, "media-commons", ); @@ -568,7 +572,13 @@ describe("FormInput - Field Visibility by Form Context", () => { it("hides Booking Type when showBookingTypes is false", () => { const schemaWithoutBookingTypes = coerceTenantSchema( - { ...rawMockTenantSchema, showBookingTypes: false }, + { + ...rawMockTenantSchema, + form: { + ...(rawMockTenantSchema.form as object), + showBookingType: false, + }, + }, "media-commons", ); diff --git a/booking-app/tests/unit/mc-booking-machine.unit.test.ts b/booking-app/tests/unit/mc-booking-machine.unit.test.ts index 4a3103971..dd0825e06 100644 --- a/booking-app/tests/unit/mc-booking-machine.unit.test.ts +++ b/booking-app/tests/unit/mc-booking-machine.unit.test.ts @@ -474,6 +474,33 @@ describe("mcBookingMachine", () => { await waitForCondition(actor, (snapshot) => snapshot.matches("Declined")); }); + it("clears declined service decisions after edit and re-approves into Services Request", async () => { + const actor = createTestActor({ + selectedRooms: [{ roomId: 202 }], + servicesRequested: { staff: true }, + }); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Pre-approved")).toBe(true); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Services Request")).toBe(true); + + actor.send({ type: "declineStaff" }); + await waitForCondition(actor, (snapshot) => snapshot.matches("Declined")); + expect(actor.getSnapshot().context.servicesApproved?.staff).toBe(false); + + actor.send({ type: "edit" }); + expect(actor.getSnapshot().matches("Requested")).toBe(true); + expect(actor.getSnapshot().context.servicesApproved).toEqual({}); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Pre-approved")).toBe(true); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Services Request")).toBe(true); + }); + it("emits the official guideline state sequence for manual service approvals", async () => { const actor = createTestActor({ selectedRooms: [{ roomId: 202 }], // No autoApproval = disabled diff --git a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts new file mode 100644 index 000000000..92e5f5cdc --- /dev/null +++ b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts @@ -0,0 +1,180 @@ +import { createRequire } from "module"; +import { describe, expect, it } from "vitest"; + +type DocumentData = Record; + +class MockFirestoreDb { + private collections = new Map>(); + readonly writes: Array<{ + collection: string; + id: string; + options: Record | undefined; + }> = []; + + constructor(seed: Record>) { + for (const [name, documents] of Object.entries(seed)) { + this.collections.set( + name, + new Map( + documents.map(({ id, data }) => [id, structuredClone(data)]), + ), + ); + } + } + + private getCollection(name: string) { + if (!this.collections.has(name)) { + this.collections.set(name, new Map()); + } + return this.collections.get(name)!; + } + + collection(name: string) { + return { + get: async () => ({ + docs: Array.from(this.getCollection(name), ([id, data]) => ({ + id, + data: () => structuredClone(data), + })), + }), + doc: (id: string) => ({ + get: async () => { + const data = this.getCollection(name).get(id); + return { + exists: data !== undefined, + data: () => structuredClone(data), + }; + }, + set: async (data: DocumentData, options?: Record) => { + this.writes.push({ collection: name, id, options }); + this.getCollection(name).set(id, structuredClone(data)); + }, + }), + }; + } + + read(name: string, id: string) { + return structuredClone(this.getCollection(name).get(id)); + } + + readCollection(name: string) { + return Array.from(this.getCollection(name), ([id, data]) => ({ + id, + data: structuredClone(data), + })); + } +} + +const requireModule = createRequire(import.meta.url); +const { migrateTenantSchemaData, migrateTenantResourceIds } = requireModule( + "../../scripts/migrateTenantResourceIds.js", +) as { + migrateTenantSchemaData: ( + schema: DocumentData, + tenantId?: string, + ) => { schema: DocumentData; changed: boolean }; + migrateTenantResourceIds: ( + db: MockFirestoreDb, + options?: { dryRun?: boolean; tenant?: string }, + ) => Promise; +}; + +describe("scripts/migrateTenantResourceIds", () => { + it("converts legacy numeric/string roomIds, removes roomId, and is idempotent", () => { + const existing = { + tenant: "mc", + resources: [ + { roomId: 101, name: "One" }, + { roomId: "room-two", name: "Two" }, + { resourceId: "canonical", name: "Three" }, + { resourceId: "same", roomId: "same", name: "Four" }, + { resourceId: 202, name: "Five" }, + { resourceId: 303, roomId: 303, name: "Six" }, + ], + }; + + const first = migrateTenantSchemaData(existing, "mc"); + expect(first.changed).toBe(true); + expect(first.schema.resources).toEqual([ + { resourceId: "101", name: "One" }, + { resourceId: "room-two", name: "Two" }, + { resourceId: "canonical", name: "Three" }, + { resourceId: "same", name: "Four" }, + { resourceId: "202", name: "Five" }, + { resourceId: "303", name: "Six" }, + ]); + + const second = migrateTenantSchemaData(first.schema, "mc"); + expect(second).toEqual({ schema: first.schema, changed: false }); + }); + + it.each([ + [ + "conflicting IDs", + [{ resourceId: "new", roomId: "old" }], + /conflicting resourceId/, + ], + ["empty IDs", [{ roomId: " " }], /must be non-empty/], + ["comma-containing IDs", [{ resourceId: "one,two" }], /must not contain commas/], + ["non-finite resource IDs", [{ resourceId: Number.NaN }], /must be a string or finite number/], + [ + "duplicate canonical IDs", + [{ roomId: 1 }, { resourceId: "1" }], + /duplicate resourceId "1"/, + ], + ])("rejects %s", (_label, resources, message) => { + expect(() => + migrateTenantSchemaData({ resources }, "mc"), + ).toThrow(message); + }); + + it("backs up and replacement-writes only changed schemas", async () => { + const db = new MockFirestoreDb({ + tenantSchema: [ + { id: "mc", data: { resources: [{ roomId: 1, name: "One" }] } }, + { id: "itp", data: { resources: [{ resourceId: "existing" }] } }, + ], + }); + + const result = await migrateTenantResourceIds(db); + + expect(result).toMatchObject({ scanned: 2, changed: 1, unchanged: 1 }); + expect(db.read("tenantSchema", "mc")).toEqual({ + resources: [{ resourceId: "1", name: "One" }], + }); + expect(db.readCollection("tenantSchemaBackup")).toHaveLength(1); + expect(db.readCollection("tenantSchemaBackup")[0].data).toEqual({ + resources: [{ roomId: 1, name: "One" }], + }); + expect( + db.writes.find( + (write) => write.collection === "tenantSchema" && write.id === "mc", + )?.options, + ).toEqual({ merge: false }); + expect( + db.writes.some( + (write) => write.collection === "tenantSchema" && write.id === "itp", + ), + ).toBe(false); + }); + + it("performs no writes in dry-run mode or when preflight validation fails", async () => { + const dryRunDb = new MockFirestoreDb({ + tenantSchema: [{ id: "mc", data: { resources: [{ roomId: 1 }] } }], + }); + const result = await migrateTenantResourceIds(dryRunDb, { dryRun: true }); + expect(result).toMatchObject({ dryRun: true, changed: 1 }); + expect(dryRunDb.writes).toHaveLength(0); + + const invalidDb = new MockFirestoreDb({ + tenantSchema: [ + { id: "mc", data: { resources: [{ roomId: 1 }] } }, + { id: "itp", data: { resources: [{ roomId: "bad,id" }] } }, + ], + }); + await expect(migrateTenantResourceIds(invalidDb)).rejects.toThrow( + /must not contain commas/, + ); + expect(invalidDb.writes).toHaveLength(0); + }); +}); diff --git a/booking-app/tests/unit/policy.unit.test.ts b/booking-app/tests/unit/policy.unit.test.ts index bfb08f803..7855afac1 100644 --- a/booking-app/tests/unit/policy.unit.test.ts +++ b/booking-app/tests/unit/policy.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { CALENDAR_HIDE_STATUS } from "@/components/src/policy"; +import { + CALENDAR_HIDE_STATUS, + getResourceApproverDocumentId, +} from "@/components/src/policy"; import { BookingStatusLabel } from "@/components/src/types"; describe("CALENDAR_HIDE_STATUS", () => { @@ -13,3 +16,11 @@ describe("CALENDAR_HIDE_STATUS", () => { expect(CALENDAR_HIDE_STATUS).toContain(BookingStatusLabel.CHECKED_OUT); }); }); + +describe("getResourceApproverDocumentId", () => { + it("normalizes resource ID whitespace and email case", () => { + expect( + getResourceApproverDocumentId(" room/a ", " Person@NYU.EDU "), + ).toBe(getResourceApproverDocumentId("room/a", "person@nyu.edu")); + }); +}); diff --git a/booking-app/tests/unit/request-limits.unit.test.ts b/booking-app/tests/unit/request-limits.unit.test.ts new file mode 100644 index 000000000..67e690d84 --- /dev/null +++ b/booking-app/tests/unit/request-limits.unit.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockServerGetDocumentById = vi.fn(); +const mockServerFetchAllDataFromCollection = vi.fn(); + +vi.mock("@/lib/firebase/server/adminDb", () => ({ + serverGetDocumentById: (...args: any[]) => mockServerGetDocumentById(...args), + serverFetchAllDataFromCollection: (...args: any[]) => + mockServerFetchAllDataFromCollection(...args), + // POST route imports these too, but our tests should short-circuit before they matter + logServerBookingChange: vi.fn(), + serverGetNextSequentialId: vi.fn(), + serverSaveDataToFirestore: vi.fn(), +})); + +vi.mock("@/components/src/server/admin", () => ({})); +vi.mock("@/components/src/server/serviceApproverNotifications", () => ({ + isServicesRequestState: () => false, + notifyServiceApproversForRequestedServices: vi.fn(), +})); +vi.mock("@/components/src/server/calendars", () => ({ + bookingContentsToDescription: vi.fn(async () => ""), + insertEvent: vi.fn(async () => ({ id: "cal-id" })), +})); +vi.mock("@/components/src/server/emails", () => ({ + getTenantEmailConfig: vi.fn(async () => ({ + schemaName: "Test Schema", + emailMessages: { + firstApprovalRequest: "", + requestConfirmation: "", + }, + })), +})); +vi.mock("@/lib/googleClient", () => ({ + getCalendarClient: vi.fn(async () => ({ + events: { list: vi.fn(async () => ({ data: { items: [] } })) }, + })), +})); +vi.mock("@/lib/utils/calendarEnvironment", () => ({ + applyEnvironmentCalendarIds: (resources: any[]) => resources, +})); +vi.mock("@/lib/stateMachines/itpBookingMachine", () => ({ + itpBookingMachine: { id: "itp", initial: "Requested" }, +})); +vi.mock("@/lib/stateMachines/mcBookingMachine", () => ({ + mcBookingMachine: { id: "mc", initial: "Requested" }, +})); +vi.mock("xstate", () => ({ + createActor: () => ({ + start: () => {}, + stop: () => {}, + getSnapshot: () => ({ value: "Requested", context: {}, can: () => false }), + getPersistedSnapshot: () => ({ value: "Requested", context: {} }), + }), +})); +vi.mock("./shared", () => ({ + extractTenantFromRequest: () => "mc", + getAffiliationDisplayValues: () => ({ + departmentDisplay: "", + schoolDisplay: "", + }), + getOtherDisplayFields: () => ({}), +})); + +vi.mock("firebase-admin/firestore", () => ({ + Timestamp: { + now: () => ({ toDate: () => new Date(), toMillis: () => Date.now() }), + fromDate: (d: Date) => ({ + toDate: () => d, + toMillis: () => d.getTime(), + }), + }, +})); + +describe("Request limits enforcement (POST /api/bookings)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("blocks when the per-period limit is reached for a resource+role", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T12:00:00Z")); + + mockServerGetDocumentById.mockResolvedValue({ + tenant: "mc", + resources: [ + { + resourceId: "room-1201", + name: "Room 1201", + requestLimits: { + perSemester: { student: 1 }, + }, + }, + ], + }); + + mockServerFetchAllDataFromCollection.mockResolvedValue([ + // Active booking in-window for same resource + { + id: "b1", + roomIds: ["room-1201"], + role: "Student", + requestedAt: { + toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), + }, + canceledAt: null, + declinedAt: null, + }, + ]); + + const { POST } = await import("@/app/api/bookings/route"); + const req = new Request("http://localhost:3000/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "student@nyu.edu", + selectedRooms: [ + { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, + ], + bookingCalendarInfo: { + startStr: "2026-04-13T14:00:00Z", + endStr: "2026-04-13T15:00:00Z", + }, + data: { + role: "Student", + title: "Test", + department: "ITP", + }, + isAutoApproval: false, + }), + }) as any; + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(429); + expect(body.error).toContain("Request limit reached"); + expect(body.error).toContain("perSemester"); + + vi.useRealTimers(); + }); + + it("matches legacy numeric room IDs after coercing them to strings", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T12:00:00Z")); + + mockServerGetDocumentById.mockResolvedValue({ + tenant: "mc", + resources: [ + { + roomId: 1201, + name: "Legacy Room 1201", + requestLimits: { + perDay: { student: 1 }, + }, + }, + ], + }); + + mockServerFetchAllDataFromCollection.mockResolvedValue([ + { + id: "b1", + roomIds: [1201], + role: "Student", + requestedAt: { + toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), + }, + }, + ]); + + const { POST } = await import("@/app/api/bookings/route"); + const req = new Request("http://localhost:3000/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "student@nyu.edu", + selectedRooms: [ + { roomId: 1201, calendarId: "cal-1201", name: "Legacy Room 1201" }, + ], + bookingCalendarInfo: { + startStr: "2026-04-13T14:00:00Z", + endStr: "2026-04-13T15:00:00Z", + }, + data: { role: "Student", title: "Test", department: "ITP" }, + isAutoApproval: false, + }), + }) as any; + + const res = await POST(req); + + expect(res.status).toBe(429); + expect(await res.json()).toMatchObject({ + error: expect.stringContaining("Legacy Room 1201"), + }); + + vi.useRealTimers(); + }); + + it("parses opaque and legacy numeric booking resource IDs as strings", async () => { + const { parseRoomIdsFromBooking } = + await import("@/lib/bookingRequestLimits"); + + expect( + parseRoomIdsFromBooking({ roomIds: ["room-a", 1201, null] }), + ).toEqual(["room-a", "1201"]); + expect(parseRoomIdsFromBooking({ roomId: "room-a, 1201" })).toEqual([ + "room-a", + "1201", + ]); + }); + + it("uses 4-month semester windows in America/New_York (May 1 starts a new window)", async () => { + const { getNewYorkWindowForPeriod, REQUEST_LIMITS_TIME_ZONE } = + await import("@/lib/bookingRequestLimits"); + const { toDate } = await import("date-fns-tz"); + const tz = REQUEST_LIMITS_TIME_ZONE; + const now = toDate("2026-05-01T12:00:00.000", { timeZone: tz }); + const { start, end } = getNewYorkWindowForPeriod( + now, + "perSemester", + undefined, + ); + + expect(start.getTime()).toBe( + toDate("2026-05-01T00:00:00.000", { timeZone: tz }).getTime(), + ); + expect(end.getTime()).toBe( + toDate("2026-09-01T00:00:00.000", { timeZone: tz }).getTime(), + ); + }); + + it("bounds the Firestore bookings query by the earliest active window start", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T12:00:00Z")); + + mockServerGetDocumentById.mockResolvedValue({ + tenant: "mc", + resources: [ + { + resourceId: "room-1201", + name: "Room 1201", + requestLimits: { + // perDay limit forces the window to be the current calendar day, + // which is much narrower than the full booking history. + perDay: { student: 5 }, + }, + }, + ], + }); + + mockServerFetchAllDataFromCollection.mockResolvedValue([]); + + const { POST } = await import("@/app/api/bookings/route"); + const req = new Request("http://localhost:3000/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "student@nyu.edu", + selectedRooms: [ + { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, + ], + bookingCalendarInfo: { + startStr: "2026-04-13T14:00:00Z", + endStr: "2026-04-13T15:00:00Z", + }, + data: { role: "Student", title: "Test", department: "ITP" }, + isAutoApproval: false, + }), + }) as any; + + await POST(req); + + const bookingQueryCall = + mockServerFetchAllDataFromCollection.mock.calls.find( + (call) => call[0] === "bookings", + ); + expect(bookingQueryCall).toBeDefined(); + const constraints = bookingQueryCall![1] as Array<{ + field: string; + operator: string; + value: any; + }>; + const requestedAtBound = constraints.find( + (c) => c.field === "requestedAt" && c.operator === ">=", + ); + expect(requestedAtBound).toBeDefined(); + // perDay window in America/New_York for 2026-04-13 12:00Z (08:00 ET) → starts 2026-04-13 00:00 ET (= 04:00Z) + const lowerBoundMs = (requestedAtBound!.value as any).toMillis(); + expect(lowerBoundMs).toBe(new Date("2026-04-13T04:00:00Z").getTime()); + + vi.useRealTimers(); + }); + + it("uses configured term ranges for perSemester windows (fallTerm) in America/New_York", async () => { + const { getNewYorkWindowForPeriod, REQUEST_LIMITS_TIME_ZONE } = + await import("@/lib/bookingRequestLimits"); + const { toDate } = await import("date-fns-tz"); + const tz = REQUEST_LIMITS_TIME_ZONE; + const now = toDate("2026-09-15T12:00:00.000", { timeZone: tz }); + const { start, end } = getNewYorkWindowForPeriod(now, "perSemester", { + fallTerm: [9, 12], + springTerm: [1, 5], + summerTerm: [6, 8], + }); + + expect(start.getTime()).toBe( + toDate("2026-09-01T00:00:00.000", { timeZone: tz }).getTime(), + ); + expect(end.getTime()).toBe( + toDate("2027-01-01T00:00:00.000", { timeZone: tz }).getTime(), + ); + }); +}); diff --git a/booking-app/tests/unit/resource-approvers-client.unit.test.ts b/booking-app/tests/unit/resource-approvers-client.unit.test.ts new file mode 100644 index 000000000..1c70b6036 --- /dev/null +++ b/booking-app/tests/unit/resource-approvers-client.unit.test.ts @@ -0,0 +1,127 @@ +import { + clientAddResourceApprover, + clientListResourceApprovers, + clientRemoveResourceApprover, + clientResolveResourceApproverEmails, +} from "@/lib/firebase/firebase"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +function response(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + }; +} + +function bodyAt(index: number) { + return JSON.parse(fetchMock.mock.calls[index][1].body as string); +} + +describe("resource approver client helpers", () => { + it("lists from the tenant resource approver collection", async () => { + fetchMock.mockResolvedValue(response({ docs: [{ id: "one" }] })); + + await expect(clientListResourceApprovers("mc")).resolves.toEqual([ + { id: "one" }, + ]); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [], + }); + }); + + it("normalizes email and uses the same deterministic ID for add/remove", async () => { + fetchMock.mockResolvedValue(response({ ok: true })); + + await clientAddResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + await clientRemoveResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + + expect(bodyAt(0)).toMatchObject({ + op: "set", + collection: "usersResourceApprovers", + tenant: "mc", + docId: bodyAt(1).docId, + data: { + email: "person@nyu.edu", + resourceId: "room/a", + }, + }); + expect(bodyAt(0).data.createdAt).toEqual({ __ts: expect.any(Number) }); + expect(bodyAt(1)).toMatchObject({ + op: "delete", + collection: "usersResourceApprovers", + tenant: "mc", + }); + }); + + it("falls back when no approver covers every requested resource", async () => { + fetchMock + .mockResolvedValueOnce( + response({ + docs: [ + { resourceId: "a", email: "SAME@nyu.edu" }, + { resourceId: "a", email: "same@nyu.edu" }, + { resourceId: "unrequested", email: "other@nyu.edu" }, + ], + }), + ) + .mockResolvedValueOnce(response({ docs: [{ email: "FINAL@nyu.edu" }] })); + + await expect( + clientResolveResourceApproverEmails(["a", "b", "b"], "mc"), + ).resolves.toEqual(["final@nyu.edu"]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [{ field: "resourceId", op: "in", value: ["a", "b"] }], + }); + expect(bodyAt(1)).toMatchObject({ + collection: "usersApprovers", + tenant: "mc", + where: [{ field: "level", op: "==", value: 2 }], + }); + }); + + it("does not query final approvers when every resource is covered", async () => { + fetchMock.mockResolvedValue( + response({ + docs: [ + { resourceId: "a", email: "ONE@nyu.edu" }, + { resourceId: "b", email: "one@nyu.edu" }, + { resourceId: "b", email: "two@nyu.edu" }, + ], + }), + ); + + await expect( + clientResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["one@nyu.edu"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [{ field: "resourceId", op: "in", value: ["a", "b"] }], + }); + }); + + it("throws instead of silently falling back after a resource query failure", async () => { + fetchMock.mockResolvedValue( + response({ error: "query failed" }, false, 500), + ); + + await expect( + clientResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("query failed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/booking-app/tests/unit/resource-approvers-server.unit.test.ts b/booking-app/tests/unit/resource-approvers-server.unit.test.ts new file mode 100644 index 000000000..dc037e7b6 --- /dev/null +++ b/booking-app/tests/unit/resource-approvers-server.unit.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Doc = { id: string; data: Record }; +type QueryFilter = { field: string; op: string; value: unknown }; + +const collections = new Map(); +const getFailures = new Map(); +const setMock = vi.fn(); +const deleteMock = vi.fn(); +const queryGetMock = vi.fn(); + +function queryFor(name: string, filters: QueryFilter[] = []) { + return { + where(field: string, _op: string, value: unknown) { + return queryFor(name, [...filters, { field, op: _op, value }]); + }, + limit() { + return this; + }, + async get() { + queryGetMock(name, filters); + const failure = getFailures.get(name); + if (failure) throw failure; + const docs = (collections.get(name) ?? []).filter(({ data }) => + filters.every(({ field, op, value }) => { + if (op === "in" && Array.isArray(value)) { + return value.includes(data[field]); + } + return data[field] === value; + }), + ); + return { + empty: docs.length === 0, + docs: docs.map((doc) => ({ id: doc.id, data: () => doc.data })), + }; + }, + }; +} + +vi.mock("@/lib/firebase/server/firebaseAdmin", () => ({ + default: { + firestore: Object.assign( + () => ({ + collection: (name: string) => ({ + ...queryFor(name), + doc: (id: string) => ({ + set: (data: unknown) => setMock(name, id, data), + delete: () => deleteMock(name, id), + }), + }), + }), + { Timestamp: { now: () => "timestamp" } }, + ), + }, +})); + +vi.mock("@/lib/newrelic-utils", () => ({ + traceDatabase: async ( + _op: string, + _label: string, + fn: () => Promise, + ) => fn(), +})); + +describe("resource approver server helpers", () => { + beforeEach(() => { + collections.clear(); + getFailures.clear(); + setMock.mockReset(); + deleteMock.mockReset(); + queryGetMock.mockReset(); + }); + + it("uses tenant collection, normalized email, and deterministic IDs", async () => { + setMock.mockResolvedValue(undefined); + deleteMock.mockResolvedValue(undefined); + const { serverAddResourceApprover, serverRemoveResourceApprover } = + await import("@/lib/firebase/server/adminDb"); + + await serverAddResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + await serverRemoveResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + + expect(setMock).toHaveBeenCalledWith( + "mc-usersResourceApprovers", + deleteMock.mock.calls[0][1], + { + email: "person@nyu.edu", + resourceId: "room/a", + createdAt: "timestamp", + }, + ); + expect(deleteMock).toHaveBeenCalledWith( + "mc-usersResourceApprovers", + expect.any(String), + ); + }); + + it("uses final fallback when no approver is assigned to every resource", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "SAME@nyu.edu" } }, + { id: "2", data: { resourceId: "a", email: "same@nyu.edu" } }, + ]); + collections.set("mc-usersApprovers", [ + { id: "final", data: { level: 2, email: "FINAL@nyu.edu" } }, + ]); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b", "b"], "mc"), + ).resolves.toEqual(["final@nyu.edu"]); + }); + + it("returns approvers assigned to every requested resource", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "one@nyu.edu" } }, + { id: "3", data: { resourceId: "b", email: "other@nyu.edu" } }, + ]); + getFailures.set("mc-usersApprovers", new Error("must not query fallback")); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["one@nyu.edu"]); + expect(queryGetMock).toHaveBeenCalledWith("mc-usersResourceApprovers", [ + { field: "resourceId", op: "in", value: ["a", "b"] }, + ]); + }); + + it("lists resource approvers by normalized email", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "two@nyu.edu" } }, + ]); + const { serverListResourceApproversByEmail } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverListResourceApproversByEmail(" ONE@NYU.EDU ", "mc"), + ).resolves.toEqual([ + { id: "1", resourceId: "a", email: "one@nyu.edu" }, + ]); + expect(queryGetMock).toHaveBeenCalledWith("mc-usersResourceApprovers", [ + { field: "email", op: "==", value: "one@nyu.edu" }, + ]); + }); + + it("falls back when resources are only covered by different approvers", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "two@nyu.edu" } }, + ]); + collections.set("mc-usersApprovers", [ + { id: "final", data: { level: 2, email: "FINAL@nyu.edu" } }, + ]); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["final@nyu.edu"]); + }); + + it("throws resource and final-approver query failures", async () => { + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + getFailures.set("mc-usersResourceApprovers", new Error("resource failure")); + await expect( + serverResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("resource failure"); + + getFailures.clear(); + getFailures.set("mc-usersApprovers", new Error("final failure")); + await expect( + serverResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("final failure"); + }); +}); diff --git a/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx new file mode 100644 index 000000000..9f5c024f5 --- /dev/null +++ b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResourceSpecific } from "@/components/src/client/routes/admin/components/ResourceSpecific"; +import { + defaultResource, + generateDefaultSchema, + SchemaProvider, +} from "@/components/src/client/routes/components/SchemaProvider"; +import { + clientAddResourceApprover, + clientListResourceApprovers, +} from "@/lib/firebase/firebase"; + +vi.mock("@/lib/firebase/firebase", () => ({ + clientAddResourceApprover: vi.fn(), + clientListResourceApprovers: vi.fn(), + clientRemoveResourceApprover: vi.fn(), +})); + +const listApproversMock = vi.mocked(clientListResourceApprovers); +const addApproverMock = vi.mocked(clientAddResourceApprover); + +describe("ResourceSpecific", () => { + beforeEach(() => { + vi.clearAllMocks(); + listApproversMock.mockResolvedValue([]); + addApproverMock.mockResolvedValue(); + }); + + it("adds a normalized approver for an opaque resource ID", async () => { + const user = userEvent.setup(); + const schema = generateDefaultSchema("tenant-one"); + schema.resources = [ + { + ...defaultResource, + name: "Audio Studio", + resourceId: "studio/a:floor-2", + }, + ]; + + render( + + + , + ); + + expect( + await screen.findByText("studio/a:floor-2 Audio Studio"), + ).toBeInTheDocument(); + expect(screen.getByText("Resource Approvers")).toBeInTheDocument(); + + const input = screen.getByLabelText( + "Resource approver email for studio/a:floor-2", + ); + await user.type(input, " Person@NYU.EDU "); + await user.click( + screen.getByRole("button", { + name: "Add resource approver for studio/a:floor-2", + }), + ); + + await waitFor(() => + expect(addApproverMock).toHaveBeenCalledWith( + "studio/a:floor-2", + "person@nyu.edu", + "tenant-one", + ), + ); + expect(screen.queryByText("Service Approvers")).not.toBeInTheDocument(); + }); +}); diff --git a/booking-app/tests/unit/router-tenant-prefix.unit.test.tsx b/booking-app/tests/unit/router-tenant-prefix.unit.test.tsx index 102c6be16..bbecf8308 100644 --- a/booking-app/tests/unit/router-tenant-prefix.unit.test.tsx +++ b/booking-app/tests/unit/router-tenant-prefix.unit.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { ThemeProvider, createTheme } from "@mui/material/styles"; +import EditLandingPage from "@/components/src/client/routes/edit/EditLandingPage"; import { useParams, useRouter } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -41,12 +42,6 @@ describe("EditLandingPage – tenant prefix in router.push", () => { }); it("navigates to /{tenant}/edit/role/{id} when Start is clicked", async () => { - const EditLandingPage = ( - await import( - "@/components/src/client/routes/edit/EditLandingPage" - ) - ).default; - render(, { wrapper: Wrapper, }); diff --git a/booking-app/tests/unit/schema-completeness.unit.test.ts b/booking-app/tests/unit/schema-completeness.unit.test.ts index ed031c7c0..2097ae827 100644 --- a/booking-app/tests/unit/schema-completeness.unit.test.ts +++ b/booking-app/tests/unit/schema-completeness.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { defaultScheme } from "../../components/src/client/routes/components/SchemaProvider"; +import { + defaultResource, + defaultScheme, +} from "../../components/src/client/routes/components/SchemaProvider"; /** * This list must match every key in SchemaContextType (including optional fields). @@ -25,6 +28,7 @@ const ALL_SCHEMA_KEYS = [ "declinedGracePeriod", "interimHighlightThresholdHours", "autoCancel", + "termConfig", "calendarConfig", "ccEmails", "emailNotifications", @@ -56,4 +60,15 @@ describe("Schema completeness", () => { `Add them to the list in schema-completeness.unit.test.ts: ${missingFromList.join(", ")}`, ).toEqual([]); }); + + it("defaultResource template uses resourceId string, not legacy roomId", () => { + expect(defaultResource).toHaveProperty("resourceId"); + expect(typeof defaultResource.resourceId).toBe("string"); + expect(defaultResource).not.toHaveProperty("roomId"); + + const itemDefaults = (defaultScheme.resources as { __defaults__?: typeof defaultResource }) + .__defaults__; + expect(itemDefaults?.resourceId).toBe(""); + expect(itemDefaults).not.toHaveProperty("roomId"); + }); }); diff --git a/booking-app/tests/unit/schema-editor-resource.unit.test.tsx b/booking-app/tests/unit/schema-editor-resource.unit.test.tsx new file mode 100644 index 000000000..038cf3798 --- /dev/null +++ b/booking-app/tests/unit/schema-editor-resource.unit.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { defaultResource } from "../../components/src/client/routes/components/schemaTypes"; +import { ResourceEditor } from "../../components/src/client/routes/super/schemaEditor"; + +describe("SchemaEditor ResourceEditor", () => { + it("edits Resource ID as text without numeric coercion", () => { + const onUpdate = vi.fn(); + + render( + , + ); + + const input = screen.getByLabelText("Resource ID"); + expect(input).not.toHaveAttribute("type", "number"); + + fireEvent.change(input, { target: { value: "studio-b2" } }); + + expect(onUpdate).toHaveBeenCalledWith(0, { + ...defaultResource, + resourceId: "studio-b2", + }); + }); +}); diff --git a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts index 8e2640acb..7ca1f46b5 100644 --- a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts +++ b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts @@ -4,20 +4,23 @@ import { mergeSchemaDefaults } from "../../scripts/schemaDefaults"; describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { it("preserves existing mapping children and does not overwrite existing minHour/maxHour", () => { const existingSchema: any = { - tenant: "mc", + tenantId: "mc", + tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, // Extra top-level keys must survive (script must be non-destructive) someExtraTopLevelKey: { keepMe: true }, // These mappings are where prod data got wiped (extra keys/values not present in template) - programMapping: { - "Template Program": ["TEMPLATE_VALUE"], - "Prod Only Program": ["PROD_CHILD_A", "PROD_CHILD_B"], - }, - roleMapping: { - Student: ["STUDENT", "PROD_EXTRA_ROLE_CHILD"], - }, - schoolMapping: { - "Tisch School of the Arts": ["ITP / IMA / Low Res"], - "Prod Only School": ["PROD_SCHOOL_CHILD"], + mappings: { + program: { + "Template Program": ["TEMPLATE_VALUE"], + "Prod Only Program": ["PROD_CHILD_A", "PROD_CHILD_B"], + }, + role: { + Student: ["STUDENT", "PROD_EXTRA_ROLE_CHILD"], + }, + school: { + "Tisch School of the Arts": ["ITP / IMA / Low Res"], + "Prod Only School": ["PROD_SCHOOL_CHILD"], + }, }, // Resources array uses __defaults__ item merge in the sync logic; we must not overwrite. resources: [ @@ -55,25 +58,26 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { // Never delete extra keys (top-level and within resource items) expect(merged.someExtraTopLevelKey).toEqual({ keepMe: true }); expect(merged.resources[0].extraResourceKey).toBe("keep-me"); + expect(merged.resources[0].resourceId).toBe("999"); + expect(merged.resources[0]).not.toHaveProperty("roomId"); // Still adds missing template defaults (spot-check a known required field) expect(merged.tenant.contextLabels).toBeTruthy(); expect(typeof merged.tenant.contextLabels.admin).toBe("string"); }); - // Regression guard: when a legacy (flat) schema is run through the - // defaults merge, the empty nested defaults that get added (emailNotifications, - // attestations, resource.training) must NOT shadow the real legacy values. - // This is the data-loss path fixed by coercing before adding defaults. - it("does not blank legacy email/agreement/training values via injected defaults", () => { - const legacySchema: any = { - tenant: "mc", - name: "Media Commons", - emailMessages: { - requestConfirmation: "Your request was received.", + // Regression guard: empty nested defaults that the merge injects + // (emailNotifications, attestations, resource.training) must NOT shadow the + // real configured values already present on the stored document. + it("does not blank configured email/attestation/training values via injected defaults", () => { + const existingSchema: any = { + tenantId: "mc", + tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, + emailNotifications: { + requestedUser: "Your request was received.", declined: "Sorry, declined.", }, - agreements: [{ id: "liability", html: "

I agree

" }], + attestations: [{ id: "liability", html: "

I agree

" }], resources: [ { name: "Trained Room", @@ -84,26 +88,28 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { isWalkIn: false, isWalkInCanBookTwo: false, services: [], - needsSafetyTraining: true, - trainingFormUrl: "https://forms.example/abc", - trainingInfoUrl: "https://info.example/abc", + training: { + required: true, + formId: "https://forms.example/abc", + infoUrl: "https://info.example/abc", + }, }, ], }; - const merged = mergeSchemaDefaults(legacySchema, "mc") as any; + const merged = mergeSchemaDefaults(existingSchema, "mc") as any; - // Legacy email messages map into emailNotifications, not blanked + // Configured email messages survive expect(merged.emailNotifications.requestedUser).toBe( "Your request was received.", ); expect(merged.emailNotifications.declined).toBe("Sorry, declined."); - // Legacy agreements survive as attestations (empty default must not win) + // Configured attestations survive (empty default must not win) expect(merged.attestations).toHaveLength(1); expect(merged.attestations[0].id).toBe("liability"); - // Legacy resource training fields survive under nested training + // Configured resource training survives expect(merged.resources[0].training.required).toBe(true); expect(merged.resources[0].training.formId).toBe( "https://forms.example/abc", @@ -111,5 +117,7 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { expect(merged.resources[0].training.infoUrl).toBe( "https://info.example/abc", ); + expect(merged.resources[0].resourceId).toBe("1234"); + expect(merged.resources[0]).not.toHaveProperty("roomId"); }); }); diff --git a/booking-app/tests/unit/update-operation-hours.unit.test.ts b/booking-app/tests/unit/update-operation-hours.unit.test.ts new file mode 100644 index 000000000..c14440a12 --- /dev/null +++ b/booking-app/tests/unit/update-operation-hours.unit.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + clientFetchAllDataFromCollection: vi.fn(), + clientSaveDataToFirestore: vi.fn(), + clientUpdateDataInFirestore: vi.fn(), +})); + +vi.mock("@/lib/firebase/firebase", () => ({ + clientFetchAllDataFromCollection: mocks.clientFetchAllDataFromCollection, + clientGetDataByCalendarEventId: vi.fn(), + clientSaveDataToFirestore: mocks.clientSaveDataToFirestore, + clientUpdateDataInFirestore: mocks.clientUpdateDataInFirestore, + getPaginatedData: vi.fn(), +})); + +vi.mock("@/lib/firebase/client/clientDb", () => ({ + clientUpdateDataByCalendarEventId: vi.fn(), +})); + +vi.mock("@/components/src/server/ui", () => ({ + getBookingToolDeployUrl: vi.fn(), +})); + +import { updateOperationHours } from "@/components/src/server/db"; +import { TableNames } from "@/components/src/policy"; +import { Days } from "@/components/src/types"; + +describe("updateOperationHours", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("matches legacy numeric operation-hours roomId values by normalized string", async () => { + mocks.clientFetchAllDataFromCollection.mockResolvedValue([ + { + id: "existing-id", + day: Days.Monday, + open: 9, + close: 17, + isClosed: false, + roomId: 101, + }, + ]); + + await updateOperationHours(Days.Monday, 10, 18, false, "101"); + + expect(mocks.clientUpdateDataInFirestore).toHaveBeenCalledWith( + TableNames.OPERATION_HOURS, + "existing-id", + { + day: Days.Monday, + open: 10, + close: 18, + isClosed: false, + roomId: 101, + }, + ); + expect(mocks.clientSaveDataToFirestore).not.toHaveBeenCalled(); + }); + + it("stores new operation-hours roomId values as strings", async () => { + mocks.clientFetchAllDataFromCollection.mockResolvedValue([]); + + await updateOperationHours(Days.Tuesday, 8, 16, false, 202); + + expect(mocks.clientSaveDataToFirestore).toHaveBeenCalledWith( + TableNames.OPERATION_HOURS, + { + day: Days.Tuesday, + open: 8, + close: 16, + isClosed: false, + roomId: "202", + }, + ); + }); +}); diff --git a/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts b/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts index eee7e0e57..da14d81a8 100644 --- a/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts +++ b/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts @@ -106,6 +106,7 @@ const mockServerDeleteData = vi.fn(); const mockServerDeleteDocumentFields = vi.fn(); const mockServerGetDocumentById = vi.fn(); const mockServerGetFinalApproverEmail = vi.fn(); +const mockServerResolveResourceApproverEmails = vi.fn(); // Mock XState utilities - but we'll use real XState machine in tests const mockExecuteXStateTransition = vi.fn(); @@ -120,6 +121,7 @@ vi.mock("@/lib/firebase/server/adminDb", () => ({ serverDeleteDocumentFields: mockServerDeleteDocumentFields, serverGetDocumentById: mockServerGetDocumentById, serverGetFinalApproverEmail: mockServerGetFinalApproverEmail, + serverResolveResourceApproverEmails: mockServerResolveResourceApproverEmails, })); // Don't mock XState utilities - we want to test the real XState machine @@ -188,6 +190,9 @@ describe("XState Approval Real Integration Tests", () => { mockServerDeleteDocumentFields.mockResolvedValue(undefined); mockServerGetDocumentById.mockResolvedValue(null); mockServerGetFinalApproverEmail.mockResolvedValue("finalapprover@nyu.edu"); + mockServerResolveResourceApproverEmails.mockResolvedValue([ + "finalapprover@nyu.edu", + ]); }); describe("1. XState Machine Transitions", () => {