diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml index e3926ba5c..9fb8e2377 100644 --- a/.github/workflows/api-validation.yml +++ b/.github/workflows/api-validation.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci + - run: npm ci --workspace=@hydroland/api --include-workspace-root=true - run: npm --workspace @hydroland/api run db:generate # Legacy migration history contains pre-production migrations whose original SQL # cannot bootstrap a clean database (for example TEXT -> UUID FK drift). @@ -37,6 +37,8 @@ jobs: # Forward migration immutability is enforced separately by Production Release Gate. - name: Bootstrap canonical schema on clean PostgreSQL run: npm --workspace @hydroland/api exec -- prisma db push --skip-generate + - name: Center license forward migration on TEXT and UUID asset keys + run: node apps/api/scripts/center-license-migration-db-e2e.mjs - name: Bootstrap raw equipment operational schema run: node apps/api/scripts/bootstrap-equipment-runtime-schema.mjs - name: Bootstrap trip weather operational schema @@ -78,8 +80,8 @@ jobs: - run: npm run validate:translation -w @hydroland/api - run: npm run validate:marine -w @hydroland/api - run: npm run validate:integrations -w @hydroland/api - - run: npm run typecheck - - run: npm run build + - run: npm run typecheck --workspace=@hydroland/api + - run: npm run build --workspace=@hydroland/api - name: Credential upload payload and legacy schema regression run: node apps/api/scripts/credential-upload-regression.mjs - name: Credential storage configuration and readiness @@ -145,6 +147,7 @@ jobs: MARINE_DOCS_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/marine-documents-http-e2e.mjs MARINE_READINESS_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/marine-readiness-http-e2e.mjs SAFETY_CHECKLIST_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/safety-checklist-http-e2e.mjs + CENTER_SAFETY_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/center-safety-http-e2e.mjs SAFETY_INCIDENTS_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/safety-incidents-http-e2e.mjs if ! PROFILE_E2E_BASE_URL=http://127.0.0.1:3101/api/v1 node apps/api/scripts/profile-account-http-e2e.mjs; then echo "Profile/Account HTTP/DB E2E failed; API log follows" diff --git a/.github/workflows/dependency-lock.yml b/.github/workflows/dependency-lock.yml index 4b07766f5..db0874b83 100644 --- a/.github/workflows/dependency-lock.yml +++ b/.github/workflows/dependency-lock.yml @@ -4,10 +4,15 @@ on: pull_request: paths: - 'package.json' + - 'package-lock.json' - 'apps/**/package.json' + - 'scripts/release-dependency-audit*' - '.github/workflows/dependency-lock.yml' workflow_dispatch: +permissions: + contents: read + jobs: lock-and-audit: runs-on: ubuntu-24.04 @@ -16,39 +21,17 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Generate deterministic lockfile - run: npm install --package-lock-only --ignore-scripts - - name: Audit high and critical vulnerabilities - run: | - npm audit --json > audit.json || true - node - <<'NODE' - const fs = require('fs'); - const report = JSON.parse(fs.readFileSync('audit.json', 'utf8')); - const vulnerabilities = report.vulnerabilities || {}; - const rows = Object.entries(vulnerabilities) - .filter(([, v]) => ['high', 'critical'].includes(v.severity)) - .map(([name, v]) => ({ - name, - severity: v.severity, - direct: v.isDirect, - range: v.range, - fixAvailable: v.fixAvailable, - via: (v.via || []).map(x => typeof x === 'string' ? x : { - source: x.source, - name: x.name, - title: x.title, - severity: x.severity, - range: x.range - }) - })); - console.log(JSON.stringify({ metadata: report.metadata?.vulnerabilities || {}, highOrCritical: rows }, null, 2)); - if (rows.length) process.exitCode = 1; - NODE - - name: Upload lockfile and audit report + - name: Validate committed lock by clean scoped installation + run: npm ci --workspace=@hydroland/api --workspace=@hydroland/web --include-workspace-root=true --ignore-scripts + - name: Reject lockfile mutation + run: git diff --exit-code -- package-lock.json + - name: Audit web/API release lock + run: node scripts/release-dependency-audit.mjs web-api audit.json + - name: Upload committed lockfile and audit report if: always() uses: actions/upload-artifact@v4 with: - name: dependency-lock-audit + name: web-api-lock-audit path: | package-lock.json audit.json diff --git a/.github/workflows/mobile-security.yml b/.github/workflows/mobile-security.yml new file mode 100644 index 000000000..1d963c131 --- /dev/null +++ b/.github/workflows/mobile-security.yml @@ -0,0 +1,36 @@ +name: Mobile release security (independent release) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + mobile-security: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci --ignore-scripts + - name: Verify fail-closed audit contract + run: node scripts/release-dependency-audit.test.mjs + - name: Block vulnerable mobile releases including build tools + run: node scripts/release-dependency-audit.mjs mobile mobile-audit.json + - name: Retain full repository security coverage + if: always() + run: node scripts/release-dependency-audit.mjs repository repository-audit.json + - name: Upload mobile and full repository reports even on failure + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobile-and-repository-security + path: | + mobile-audit.json + repository-audit.json + retention-days: 7 diff --git a/.github/workflows/mobile-validation.yml b/.github/workflows/mobile-validation.yml index 07837ea3e..01b4ae421 100644 --- a/.github/workflows/mobile-validation.yml +++ b/.github/workflows/mobile-validation.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci + - run: npm ci --workspace=@hydroland/mobile --include-workspace-root=false - run: npm --workspace @hydroland/mobile run test - run: npm --workspace @hydroland/mobile run typecheck - run: npm --workspace @hydroland/mobile run build diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml index b619b5678..d67bcd312 100644 --- a/.github/workflows/pages-preview.yml +++ b/.github/workflows/pages-preview.yml @@ -25,10 +25,13 @@ jobs: node-version: 22 - name: Install dependencies - run: npm ci + run: npm ci --workspace=@hydroland/web --include-workspace-root=true --ignore-scripts - name: Validate - run: npm run typecheck + run: npm run test --workspace=@hydroland/web + + - name: Audit web/API release before publishing web artifact + run: node scripts/release-dependency-audit.mjs web-api audit.json - name: Build web run: npm run build --workspace @hydroland/web diff --git a/.github/workflows/phase11-production-security.yml b/.github/workflows/phase11-production-security.yml index 6b4e4b053..8d9d5c4a2 100644 --- a/.github/workflows/phase11-production-security.yml +++ b/.github/workflows/phase11-production-security.yml @@ -9,8 +9,15 @@ on: - 'apps/api/src/main.ts' - 'apps/api/src/auth/**' - 'apps/api/scripts/phase11-production-security-validation.mjs' + - 'apps/api/scripts/runtime-dependency-boundary.mjs' + - 'apps/api/package.json' + - 'package.json' + - 'package-lock.json' - 'infra/**' - '.github/workflows/security-audit.yml' + - '.github/workflows/mobile-security.yml' + - 'scripts/release-dependency-audit*' + - 'scripts/build-*-release.mjs' - '.github/workflows/phase11-production-security.yml' pull_request: paths: @@ -19,14 +26,34 @@ on: - 'apps/api/src/main.ts' - 'apps/api/src/auth/**' - 'apps/api/scripts/phase11-production-security-validation.mjs' + - 'apps/api/scripts/runtime-dependency-boundary.mjs' + - 'apps/api/package.json' + - 'package.json' + - 'package-lock.json' - 'infra/**' - '.github/workflows/security-audit.yml' + - '.github/workflows/mobile-security.yml' + - 'scripts/release-dependency-audit*' + - 'scripts/build-*-release.mjs' - '.github/workflows/phase11-production-security.yml' workflow_dispatch: jobs: validate: runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: hydroland + POSTGRES_PASSWORD: hydroland + POSTGRES_DB: hydroland_image_test + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U hydroland -d hydroland_image_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://hydroland:hydroland@127.0.0.1:5432/hydroland_image_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -36,6 +63,72 @@ jobs: run: node apps/api/scripts/phase11-production-security-validation.mjs - name: Build production API image run: docker build -t hydroland-api:phase11 . + - name: Build Compose API image + run: docker build -f apps/api/Dockerfile -t hydroland-api:compose . - name: Verify image runs as non-root user run: | - test "$(docker image inspect hydroland-api:phase11 --format '{{.Config.User}}')" = "node" + for image in hydroland-api:phase11 hydroland-api:compose; do + test "$(docker image inspect "$image" --format '{{.Config.User}}')" = "node" + done + - name: Verify installed runtime boundary and offline Prisma CLI + run: | + for image in hydroland-api:phase11 hydroland-api:compose; do + label="${image##*:}" + docker run --rm --network none --read-only --tmpfs /tmp --entrypoint node "$image" \ + apps/api/scripts/runtime-dependency-boundary.mjs > "api-runtime-$label.json" + docker run --rm --network none --read-only --tmpfs /tmp --entrypoint node "$image" \ + node_modules/prisma/build/index.js --version + done + - name: Audit API image dependencies (additional to full repository gate) + run: | + docker run --rm --entrypoint npm hydroland-api:phase11 \ + audit --workspace=@hydroland/api --include-workspace-root=false --omit=dev --audit-level=high + - name: Verify final image starts and queries disposable PostgreSQL + run: | + # Historical migrations cannot bootstrap an empty database. This smoke + # checks runtime/engine readiness, not production migration acceptance. + docker run --rm --network host -e DATABASE_URL --entrypoint node hydroland-api:phase11 \ + node_modules/prisma/build/index.js db push --schema apps/api/prisma/schema.prisma --skip-generate + docker run -d --name hydroland-image-smoke --network host --read-only --tmpfs /tmp \ + -e DATABASE_URL -e PORT=3102 -e WEB_ORIGIN=http://127.0.0.1:4173 \ + -e JWT_SECRET=ci-image-smoke-secret-at-least-32-characters \ + --entrypoint node hydroland-api:phase11 apps/api/dist/main.js + trap 'docker logs hydroland-image-smoke; docker rm -f hydroland-image-smoke' EXIT + for attempt in {1..30}; do + if curl -fsS http://127.0.0.1:3102/api/v1/health/ready > /tmp/api-image-ready.json; then + node -e 'const r=require("/tmp/api-image-ready.json"); if(r.status!=="ready"||r.database!=="ok")process.exit(1)' + exit 0 + fi + sleep 1 + done + exit 1 + - name: Verify native Render API build command and retained Prisma engine + run: | + cd apps/api + node ../../scripts/build-api-release.mjs > /tmp/native-api-build.log 2>&1 || { cat /tmp/native-api-build.log; exit 1; } + node ../../node_modules/prisma/build/index.js --version + node ../../node_modules/prisma/build/index.js db push --schema prisma/schema.prisma --skip-generate + JWT_SECRET=ci-native-api-secret-at-least-32-characters WEB_ORIGIN=http://127.0.0.1:4173 PORT=3103 NODE_ENV=production \ + node dist/main.js > /tmp/native-api.log 2>&1 & + api_pid=$! + trap 'cat /tmp/native-api.log; kill "$api_pid" || true' EXIT + for attempt in {1..30}; do + if curl -fsS http://127.0.0.1:3103/api/v1/health/ready > /tmp/native-api-ready.json; then + node -e 'const r=require("/tmp/native-api-ready.json"); if(r.status!=="ready"||r.database!=="ok")process.exit(1)' + exit 0 + fi + sleep 1 + done + exit 1 + - name: Verify native Render web build command + run: node scripts/build-web-release.mjs + - name: Retain installed runtime inventories + if: always() + uses: actions/upload-artifact@v4 + with: + name: api-runtime-inventories + path: | + api-runtime-*.json + api-release-audit.json + web-release-audit.json + retention-days: 7 diff --git a/.github/workflows/production-release-gate.yml b/.github/workflows/production-release-gate.yml index 3a052d05a..2ccbfd767 100644 --- a/.github/workflows/production-release-gate.yml +++ b/.github/workflows/production-release-gate.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci + - run: npm ci --workspace=@hydroland/api --workspace=@hydroland/web --include-workspace-root=true - run: npm --workspace @hydroland/api run db:generate - name: Verify production-applied migration checksums run: node apps/api/scripts/production-migration-baseline-validation.mjs @@ -40,11 +40,11 @@ jobs: npm run validate:translation -w @hydroland/api npm run validate:marine -w @hydroland/api - name: Test - run: npm test + run: npm test --workspace=@hydroland/api --workspace=@hydroland/web --if-present - name: Typecheck - run: npm run typecheck + run: npm run typecheck --workspace=@hydroland/api --workspace=@hydroland/web --if-present - name: Build - run: npm run build + run: npm run build --workspace=@hydroland/api --workspace=@hydroland/web --if-present - name: Confirm production remains explicit shell: bash run: | diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index b62572ea0..dc3aaf8f7 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci + - run: npm ci --workspace=@hydroland/api --workspace=@hydroland/web --include-workspace-root=true - run: npm --workspace @hydroland/api run db:generate - name: API domain validations run: | @@ -29,9 +29,9 @@ jobs: npm run validate:translation -w @hydroland/api npm run validate:marine -w @hydroland/api npm run validate:integrations -w @hydroland/api - - name: Workspace tests - run: npm test - - name: Typecheck all workspaces - run: npm run typecheck - - name: Build all workspaces - run: npm run build + - name: Web/API release tests + run: npm test --workspace=@hydroland/api --workspace=@hydroland/web --if-present + - name: Typecheck web/API release + run: npm run typecheck --workspace=@hydroland/api --workspace=@hydroland/web --if-present + - name: Build web/API release + run: npm run build --workspace=@hydroland/api --workspace=@hydroland/web --if-present diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index e45ed781d..e618106c0 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -6,37 +6,29 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: audit: + # User-approved release boundary: web/API/root, including build tools. + # Mobile and the full repository retain a separate failing security gate. runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - name: Install dependencies - run: npm ci - - name: Generate dependency audit report and enforce severity gate - shell: bash - run: | - npm audit --json > audit.json || true - node - <<'NODE' - const fs=require('fs'); - const report=JSON.parse(fs.readFileSync('audit.json','utf8')); - const vulns=report.vulnerabilities||{}; - const rows=Object.entries(vulns) - .filter(([,v])=>['high','critical'].includes(v.severity)) - .map(([name,v])=>({name,severity:v.severity,direct:v.isDirect,range:v.range,fixAvailable:v.fixAvailable,via:(v.via||[]).map(x=>typeof x==='string'?x:{source:x.source,name:x.name,title:x.title,severity:x.severity,range:x.range})})); - console.log(JSON.stringify({metadata:report.metadata?.vulnerabilities||{},highOrCritical:rows},null,2)); - if(rows.length){ - console.error(`Security gate failed: ${rows.length} high/critical package vulnerabilities detected.`); - process.exit(1); - } - NODE + - name: Install web and API release dependencies + run: npm ci --workspace=@hydroland/api --workspace=@hydroland/web --include-workspace-root=true + - name: Verify fail-closed audit contract + run: node scripts/release-dependency-audit.test.mjs + - name: Audit web/API release including build dependencies + run: node scripts/release-dependency-audit.mjs web-api audit.json - name: Upload audit report if: always() uses: actions/upload-artifact@v4 with: - name: npm-audit-report + name: web-api-dependency-audit path: audit.json retention-days: 7 diff --git a/.github/workflows/web-validation.yml b/.github/workflows/web-validation.yml index d13dd82c8..75c4644ae 100644 --- a/.github/workflows/web-validation.yml +++ b/.github/workflows/web-validation.yml @@ -5,11 +5,15 @@ on: pull_request: paths: - 'apps/web/**' + - 'package.json' + - 'package-lock.json' - '.github/workflows/web-validation.yml' push: branches: [main] paths: - 'apps/web/**' + - 'package.json' + - 'package-lock.json' - '.github/workflows/web-validation.yml' jobs: @@ -21,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci --ignore-scripts + - run: npm ci --workspace=@hydroland/web --include-workspace-root=true --ignore-scripts - name: Static web validation run: npm run test -w @hydroland/web - name: Production web build @@ -31,7 +35,8 @@ jobs: - name: Browser E2E run: npm run test:e2e -w @hydroland/web - name: Phase 4 notification focused regression - run: npm run test:e2e -w @hydroland/web -- safety-notifications.spec.js + # Keep the full-suite test-results directory intact for the visual-review artifact steps below. + run: npm run test:e2e -w @hydroland/web -- safety-notifications.spec.js --output=test-results-focused - name: Public interface review screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/Dockerfile b/Dockerfile index 961514408..8437bd1c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,32 @@ -FROM node:22-bookworm-slim AS build - +FROM node:22-bookworm-slim AS base +# Prisma's generated Linux engine and migration CLI require OpenSSL at runtime. +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app + +FROM base AS manifests COPY package.json package-lock.json ./ COPY apps/api/package.json apps/api/package.json COPY apps/web/package.json apps/web/package.json COPY apps/mobile/package.json apps/mobile/package.json -RUN npm ci --ignore-scripts + +FROM manifests AS build +RUN npm ci --workspace=@hydroland/api --include-workspace-root=false --ignore-scripts COPY apps/api apps/api RUN npm run db:generate --workspace=@hydroland/api \ && npm run build --workspace=@hydroland/api -FROM node:22-bookworm-slim AS runtime -WORKDIR /app -ENV NODE_ENV=production +FROM manifests AS runtime-dependencies +RUN npm ci --workspace=@hydroland/api --include-workspace-root=false --omit=dev --ignore-scripts \ + && mkdir -p apps/api/node_modules -COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./ -COPY --from=build --chown=node:node /app/node_modules ./node_modules +FROM base AS runtime +ENV NODE_ENV=production +COPY --from=runtime-dependencies --chown=node:node /app/package.json /app/package-lock.json ./ +COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules +COPY --from=runtime-dependencies --chown=node:node /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=build --chown=node:node /app/node_modules/@prisma/engines ./node_modules/@prisma/engines +COPY --from=build --chown=node:node /app/node_modules/.prisma ./node_modules/.prisma COPY --from=build --chown=node:node /app/apps/api/package.json ./apps/api/package.json COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist COPY --from=build --chown=node:node /app/apps/api/prisma ./apps/api/prisma @@ -23,6 +34,5 @@ COPY --from=build --chown=node:node /app/apps/api/scripts ./apps/api/scripts EXPOSE 10000 USER node - -# Database migrations run before the API accepts requests. Runtime secrets are injected by the platform. -CMD ["sh", "-c", "npm run db:deploy --workspace=@hydroland/api && node apps/api/dist/main.js"] +# Runtime secrets are injected by the platform; preserve migration-before-start. +CMD ["sh", "-c", "npm run db:deploy --workspace=@hydroland/api && exec node apps/api/dist/main.js"] diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index ac99dfd45..f4ca1c3a4 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,17 +1,38 @@ -FROM node:22-alpine AS build +FROM node:22-bookworm-slim AS base +# Prisma's generated Linux engine and migration CLI require OpenSSL at runtime. +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app + +FROM base AS manifests COPY package.json package-lock.json ./ COPY apps/api/package.json apps/api/package.json COPY apps/web/package.json apps/web/package.json COPY apps/mobile/package.json apps/mobile/package.json -RUN npm ci --ignore-scripts + +FROM manifests AS build +RUN npm ci --workspace=@hydroland/api --include-workspace-root=false --ignore-scripts COPY apps/api apps/api -RUN npm run build -w @hydroland/api -FROM node:22-alpine -WORKDIR /app +RUN npm run db:generate --workspace=@hydroland/api \ + && npm run build --workspace=@hydroland/api + +FROM manifests AS runtime-dependencies +RUN npm ci --workspace=@hydroland/api --include-workspace-root=false --omit=dev --ignore-scripts \ + && mkdir -p apps/api/node_modules + +FROM base AS runtime ENV NODE_ENV=production -COPY --from=build /app/node_modules node_modules -COPY --from=build /app/apps/api/dist apps/api/dist +COPY --from=runtime-dependencies --chown=node:node /app/package.json /app/package-lock.json ./ +COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules +COPY --from=runtime-dependencies --chown=node:node /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=build --chown=node:node /app/node_modules/@prisma/engines ./node_modules/@prisma/engines +COPY --from=build --chown=node:node /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=build --chown=node:node /app/apps/api/package.json ./apps/api/package.json +COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist +COPY --from=build --chown=node:node /app/apps/api/prisma ./apps/api/prisma +COPY --from=build --chown=node:node /app/apps/api/scripts ./apps/api/scripts + EXPOSE 3001 USER node -CMD ["node","apps/api/dist/main.js"] +# Compose retains its existing direct-start contract. +CMD ["node", "apps/api/dist/main.js"] diff --git a/apps/api/package.json b/apps/api/package.json index 2a70468a0..d39d3d2a3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -60,12 +60,12 @@ "@pdf-lib/fontkit": "^1.1.1", "bidi-js": "^1.0.3", "@fontsource/noto-sans-arabic": "^5.2.8", - "google-auth-library": "^11.1.0" + "google-auth-library": "^11.1.0", + "prisma": "^6.19.3" }, "devDependencies": { "@nestjs/cli": "^11.0.0", "@types/node": "^22.10.0", - "prisma": "^6.19.3", "typescript": "^5.7.0" } } diff --git a/apps/api/prisma/migrations/20261003080000_center_license_attachments/migration.sql b/apps/api/prisma/migrations/20261003080000_center_license_attachments/migration.sql new file mode 100644 index 000000000..c4234b61e --- /dev/null +++ b/apps/api/prisma/migrations/20261003080000_center_license_attachments/migration.sql @@ -0,0 +1,17 @@ +-- Follow the deployed asset key type (canonical TEXT or recovered UUID). +DO $$ +DECLARE asset_key_type text; +BEGIN + SELECT format_type(atttypid, atttypmod) INTO asset_key_type + FROM pg_attribute WHERE attrelid='"OrganizationDocumentAsset"'::regclass AND attname='id' AND NOT attisdropped; + IF asset_key_type NOT IN ('text','uuid') THEN RAISE EXCEPTION 'Unsupported organization asset key type'; END IF; + EXECUTE format('ALTER TABLE "AdministrativeRecord" ADD COLUMN "licenseAssetId" %s', asset_key_type); +END $$; +ALTER TABLE "AdministrativeRecord" + ADD COLUMN "licenseIssuedAt" TIMESTAMP(3), + ADD COLUMN "licenseExpiresAt" TIMESTAMP(3), + ADD CONSTRAINT "AdministrativeRecord_licenseAssetId_fkey" FOREIGN KEY ("licenseAssetId") REFERENCES "OrganizationDocumentAsset"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + ADD CONSTRAINT "AdministrativeRecord_license_dates_check" CHECK ( + ("licenseAssetId" IS NULL AND "licenseIssuedAt" IS NULL AND "licenseExpiresAt" IS NULL) OR + ("licenseAssetId" IS NOT NULL AND "licenseIssuedAt" IS NOT NULL AND "licenseExpiresAt" IS NOT NULL AND "licenseExpiresAt">"licenseIssuedAt") + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9efa0d38e..0740f3b21 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -259,6 +259,7 @@ model OrganizationDocumentAsset { byteSize Int sha256 String content Bytes + licenseRecords AdministrativeRecord[] @relation("LicenseAttachment") createdAt DateTime @default(now()) organization Organization @relation(fields:[organizationId], references:[id], onDelete:Restrict) @@unique([organizationId,sha256]) @@ -459,6 +460,10 @@ model AdministrativeRecord { referenceNumber String subject String status AdministrativeRecordDbStatus @default(DRAFT) + licenseAssetId String? + licenseIssuedAt DateTime? + licenseExpiresAt DateTime? + licenseAsset OrganizationDocumentAsset? @relation("LicenseAttachment", fields:[licenseAssetId], references:[id], onDelete:Restrict) ownerAccountId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/scripts/center-equipment-http-checks.mjs b/apps/api/scripts/center-equipment-http-checks.mjs new file mode 100644 index 000000000..a4f477d68 --- /dev/null +++ b/apps/api/scripts/center-equipment-http-checks.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; + +// Called only by the CI + loopback guarded center HTTP suite. The temporary +// column starts with a real TEXT FK; UUID mode tests query compatibility, not +// a production schema migration or its foreign-key recovery. +export async function checkCenterEquipment(db,{base,a,b,ownerA,ta,tb,ts},check){ + if(process.env.CI!=='true'||!['localhost','127.0.0.1','[::1]'].includes(new URL(process.env.DATABASE_URL).hostname))throw new Error('CI loopback database required'); + const columns=await db.$queryRaw`SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='EquipmentBarcode' AND column_name='organizationId'`; + assert.equal(columns.length,0,'Expected isolated bootstrap schema without center scope'); + const resources=[]; + const policies=await db.$queryRaw`SELECT "id","state" FROM "PolicyControl" WHERE "category"='EQUIPMENT' AND "ruleKey" IN ('INSPECTION_STATUS','SERVICE_EXPIRY')`; + const request=async(token,path,body)=>{ + const response=await fetch(base+'/center/me/equipment'+path,{method:body?'PATCH':'GET',headers:{...(token?{authorization:'Bearer '+token}:{}),...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}); + return {status:response.status,body:await response.json()}; + }; + try{ + await db.$executeRaw`ALTER TABLE "EquipmentBarcode" ADD COLUMN "organizationId" TEXT REFERENCES "Organization"("id")`; + await db.$executeRaw`UPDATE "PolicyControl" SET "state"='ENABLED' WHERE "category"='EQUIPMENT' AND "ruleKey" IN ('INSPECTION_STATUS','SERVICE_EXPIRY')`; + for(const keyType of ['TEXT','UUID']){ + if(keyType==='UUID'){ + await db.$executeRaw`ALTER TABLE "EquipmentBarcode" DROP CONSTRAINT "EquipmentBarcode_organizationId_fkey"`; + await db.$executeRaw`ALTER TABLE "EquipmentBarcode" ALTER COLUMN "organizationId" TYPE UUID USING "organizationId"::uuid`; + } + const fixtures=[]; + for(const center of [a,b]){ + const resource=await db.calendarResource.create({data:{type:'EQUIPMENT',name:'CI center equipment '+randomUUID()}}); + resources.push(resource.id); + const code='CI-'+randomUUID(); + await db.$executeRaw`INSERT INTO "EquipmentBarcode"("id","resourceId","assetCode","barcodeValue","qrValue","location","organizationId") VALUES(${randomUUID()},${resource.id},${code},${code},${'qr:'+code},'STORE',(jsonb_populate_record(NULL::"EquipmentBarcode",jsonb_build_object('organizationId',${center.org.id}::text)))."organizationId")`; + fixtures.push({id:resource.id,code}); + } + const [own,other]=fixtures; + const move=(body,token=ta,id=own.id)=>request(token,`/${id}/move`,body); + let r=await request(ta,''); + check(r.status===200&&r.body.some(row=>row.resourceId===own.id)&&!r.body.some(row=>row.resourceId===other.id),`${keyType}: equipment listing is center-scoped`); + r=await request(ta,'/lookup/'+own.code);check(r.status===200&&r.body.resourceId===own.id,`${keyType}: owned lookup succeeds`); + check((await request(ta,'/lookup/'+other.code)).status===404,`${keyType}: cross-center lookup denied`); + check((await request(tb,`/${own.id}/history`)).status===404,`${keyType}: cross-center history denied`); + check((await move({movementType:'TRANSFER'},tb)).status===404,`${keyType}: cross-center write denied`); + check((await move({movementType:'TRANSFER'},ts)).status===403,`${keyType}: ordinary staff write denied`); + check((await request(undefined,`/${own.id}/move`,{movementType:'TRANSFER'})).status===401,`${keyType}: anonymous write denied`); + check((await move({movementType:'INVALID'})).status===400,`${keyType}: invalid movement denied`); + check((await move({movementType:'TRANSFER',tripId:b.trip.id})).status===404,`${keyType}: other center trip denied`); + await db.trip.update({where:{id:a.trip.id},data:{status:'COMPLETED'}}); + check((await move({movementType:'TRANSFER',tripId:a.trip.id})).status===409,`${keyType}: completed trip denied`); + await db.trip.update({where:{id:a.trip.id},data:{status:'OPEN'}}); + check((await move({movementType:'CHECK_IN'})).status===409,`${keyType}: invalid check-in denied`); + check((await move({movementType:'CHECK_OUT'})).status===409,`${keyType}: missing inspection blocks check-out`); + const inspectionId=randomUUID(); + await db.$executeRaw`INSERT INTO "EquipmentInspection"("id","resourceId","status","inspectedAt","serviceExpiresAt") VALUES(${inspectionId},${own.id},'FAIL',NOW(),NOW()+INTERVAL '1 year')`; + check((await move({movementType:'CHECK_OUT'})).status===409,`${keyType}: failed inspection blocks check-out`); + await db.$executeRaw`UPDATE "EquipmentInspection" SET "status"='PASS',"serviceExpiresAt"=NOW()-INTERVAL '1 day' WHERE "id"=${inspectionId}`; + check((await move({movementType:'CHECK_OUT'})).status===409,`${keyType}: expired service blocks check-out`); + r=await request(ta,`/${own.id}/history`);check(r.status===200&&r.body.length===0,`${keyType}: denied writes leave no movements`); + check(await db.auditEvent.count({where:{resourceId:own.id,action:'EQUIPMENT_INVENTORY_MOVED'}})===0,`${keyType}: denied writes leave no success audit`); + await db.$executeRaw`UPDATE "EquipmentInspection" SET "serviceExpiresAt"=NOW()+INTERVAL '1 year' WHERE "id"=${inspectionId}`; + const sequence=[['CHECK_OUT','CHECKED_OUT'],['CHECK_IN','AVAILABLE'],['MAINTENANCE','MAINTENANCE'],['RELEASE','AVAILABLE'],['QUARANTINE','QUARANTINED'],['RELEASE','AVAILABLE'],['TRANSFER','AVAILABLE'],['RETIRE','RETIRED']]; + for(const [movementType,status] of sequence){ + r=await move({movementType,toLocation:'CI-'+movementType,tripId:a.trip.id,notes:'Isolated HTTP test'}); + check(r.status===200&&r.body.stockStatus===status,`${keyType}: ${movementType} succeeds with ${status}, got ${r.status}`); + const audit=await db.auditEvent.findFirst({where:{resourceId:own.id,action:'EQUIPMENT_INVENTORY_MOVED'},orderBy:{occurredAt:'desc'}}); + check(audit?.actorId===ownerA.personId&&audit.metadata.movementId===r.body.movement.id&&audit.metadata.organizationId===a.org.id&&audit.metadata.stockStatus===status,`${keyType}: ${movementType} audit records actor, center and movement`); + } + check((await move({movementType:'RELEASE'})).status===409,`${keyType}: retired equipment stays retired`); + r=await request(ta,`/${own.id}/history`);check(r.status===200&&r.body.length===sequence.length,`${keyType}: history contains exactly successful movements`); + r=await request(tb,'/lookup/'+other.code);check(r.status===200&&r.body.stockStatus==='AVAILABLE'&&r.body.location==='STORE',`${keyType}: other center equipment unchanged`); + } + }finally{ + await db.auditEvent.deleteMany({where:{resourceId:{in:resources},action:'EQUIPMENT_INVENTORY_MOVED'}}); + await db.$executeRaw`DELETE FROM "EquipmentMovement" WHERE "resourceId"=ANY(${resources}::text[])`; + await db.$executeRaw`DELETE FROM "EquipmentInspection" WHERE "resourceId"=ANY(${resources}::text[])`; + await db.$executeRaw`DELETE FROM "EquipmentBarcode" WHERE "resourceId"=ANY(${resources}::text[])`; + await db.calendarResource.deleteMany({where:{id:{in:resources}}}); + for(const policy of policies)await db.$executeRaw`UPDATE "PolicyControl" SET "state"=${policy.state} WHERE "id"::text=${policy.id}`; + await db.$executeRaw`ALTER TABLE "EquipmentBarcode" DROP COLUMN IF EXISTS "organizationId"`; + } +} diff --git a/apps/api/scripts/center-license-create-http-checks.mjs b/apps/api/scripts/center-license-create-http-checks.mjs new file mode 100644 index 000000000..f40ce16cd --- /dev/null +++ b/apps/api/scripts/center-license-create-http-checks.mjs @@ -0,0 +1,58 @@ +import {randomUUID} from 'node:crypto'; +export async function checkCenterLicenseCreation(db,{base,a,b,ownerA,ta,tb,ts},check){ + const unit=await db.orgUnit.findFirstOrThrow({where:{organizationId:a.org.id,active:true}}); + const otherUnit=await db.orgUnit.findFirstOrThrow({where:{organizationId:b.org.id,active:true}}); + const source=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'LICENSE',status:'REGISTERED'}}); + const foreign=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:b.org.id,type:'LICENSE'}}); + const created=[]; + const body={type:'LICENSE',unitId:unit.id,referenceNumber:'NEW-'+randomUUID(),subject:'رخصة جديدة'}; + const post=async(token,payload=body,path='/center/me/licenses')=>{ + const r=await fetch(base+path,{method:'POST',headers:{...(token?{authorization:'Bearer '+token}:{}),'content-type':'application/json'},body:JSON.stringify(payload)}); + const data=await r.json();if(r.status===201)created.push(data.id);return{status:r.status,body:data}; + }; + try{ + const list=await fetch(base+'/center/me/documents',{headers:{authorization:'Bearer '+ta}});const view=await list.json(); + check(view.units.some(x=>x.id===unit.id)&&!view.units.some(x=>x.id===otherUnit.id),'Creation unit choices stay within center'); + check(view.units.every(x=>!('organizationId' in x)),'Creation choices expose only needed unit metadata'); + check((await post(undefined)).status===401,'Guest cannot create license record'); + check((await post(ts)).status===403,'Ordinary staff cannot create license record'); + check((await post(tb)).status===404,'Other center cannot use source center unit'); + check((await post(ta,{...body,unitId:otherUnit.id})).status===404,'Cannot select foreign unit'); + for(const invalid of [{type:'INTERNAL_MEMO'},{unitId:null},{referenceNumber:' '},{referenceNumber:'x'.repeat(121)},{subject:' '},{subject:123},{subject:'x'.repeat(241)}])check((await post(ta,{...body,...invalid})).status===400,'Reject invalid creation field '+Object.keys(invalid)[0]); + await db.orgUnit.update({where:{id:unit.id},data:{active:false}}); + check((await post(ta)).status===404,'Inactive unit cannot receive new license'); + await db.orgUnit.update({where:{id:unit.id},data:{active:true}}); + let r=await post(ta,{...body,organizationId:b.org.id,ownerAccountId:'spoofed',status:'REGISTERED',licenseAssetId:'spoofed'}); + check(r.status===201&&r.body.status==='DRAFT','Manager creates draft, never pre-approved record'); + const fresh=await db.administrativeRecord.findUniqueOrThrow({where:{id:r.body.id}}); + check(fresh.organizationId===a.org.id&&fresh.ownerAccountId===ownerA.id&&fresh.licenseAssetId===null,'Server owns organization/owner and ignores attachment/status spoofing'); + check((await post(ta)).status===409,'Duplicate center reference rejected'); + const audit=await db.auditEvent.findFirst({where:{resourceId:fresh.id,action:'CENTER_LICENSE_CREATED'}}); + check(audit?.actorId===ownerA.personId&&audit.metadata.organizationId===a.org.id,'Creation audit binds actor and center'); + const renewBody={referenceNumber:'RENEW-'+randomUUID(),subject:'تجديد الرخصة'}; + const renew='/center/me/licenses/'+source.id+'/renew'; + check((await post(undefined,renewBody,renew)).status===401,'Guest cannot renew'); + check((await post(ts,renewBody,renew)).status===403,'Ordinary staff cannot renew'); + check((await post(tb,renewBody,renew)).status===404,'Other center cannot renew source record'); + check((await post(ta,renewBody,'/center/me/licenses/'+foreign.id+'/renew')).status===404,'Cannot renew foreign license'); + check((await post(ta,renewBody,'/center/me/licenses/'+fresh.id+'/renew')).status===409,'Existing draft cannot be renewed'); + check((await post(ta,{...renewBody,referenceNumber:source.referenceNumber},renew)).status===409,'Renewal needs a new reference'); + r=await post(ta,{...renewBody,unitId:otherUnit.id,type:'INTERNAL_MEMO'},renew); + check(r.status===201&&r.body.id!==source.id&&r.body.status==='DRAFT','Renewal creates separate draft'); + const renewed=await db.administrativeRecord.findUniqueOrThrow({where:{id:r.body.id}}); + check(renewed.unitId===source.unitId&&renewed.type===source.type&&renewed.licenseAssetId===null&&renewed.licenseIssuedAt===null&&renewed.licenseExpiresAt===null,'Renewal inherits scope/type, not stale attachment or dates'); + const after=await db.administrativeRecord.findUniqueOrThrow({where:{id:source.id}}); + check(JSON.stringify(after)===JSON.stringify(source),'Renewal leaves old record and attachment unchanged'); + const renewalAudit=await db.auditEvent.findFirst({where:{resourceId:renewed.id,action:'CENTER_LICENSE_RENEWAL_CREATED'}}); + check(renewalAudit?.metadata.renewalOfRecordId===source.id&&renewalAudit.metadata.previousReferenceNumber===source.referenceNumber,'Audit records renewal provenance'); + check(await db.administrativeRouting.count({where:{recordId:renewed.id}})===0,'Renewal cannot inherit previous approval'); + const concurrent={...body,referenceNumber:'CONCURRENT-'+randomUUID()}; + const results=await Promise.all([post(ta,concurrent),post(ta,concurrent)]); + check(results.filter(x=>x.status===201).length===1&&results.filter(x=>x.status===409).length===1,'Concurrent duplicate submissions create one record'); + check(await db.administrativeRecord.count({where:{organizationId:a.org.id,referenceNumber:concurrent.referenceNumber}})===1,'Exactly one concurrent record persists'); + }finally{ + await db.orgUnit.update({where:{id:unit.id},data:{active:true}}); + await db.auditEvent.deleteMany({where:{resourceId:{in:created},action:{in:['CENTER_LICENSE_CREATED','CENTER_LICENSE_RENEWAL_CREATED']}}}); + await db.administrativeRecord.deleteMany({where:{id:{in:created}}}); + } +} diff --git a/apps/api/scripts/center-license-http-checks.mjs b/apps/api/scripts/center-license-http-checks.mjs new file mode 100644 index 000000000..75dc5c00d --- /dev/null +++ b/apps/api/scripts/center-license-http-checks.mjs @@ -0,0 +1,62 @@ +import {PDFDocument} from 'pdf-lib'; +import {randomUUID,createHash} from 'node:crypto'; +export async function checkCenterLicenses(db,{base,a,b,ownerA,staff,ta,tb,ts},check){ + const record=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'LICENSE'}}); + const memo=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'INTERNAL_MEMO'}}); + const pdf=await PDFDocument.create();pdf.addPage();const bytes=Buffer.from(await pdf.save()); + const input={mimeType:'application/pdf',base64:bytes.toString('base64'),issuedAt:'2025-01-01',expiresAt:'2030-01-01'}; + const request=async(token,path,method='GET',body)=>fetch(base+path,{method,headers:{...(token?{authorization:'Bearer '+token}:{}),...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}); + const path='/center/me/licenses/'+record.id+'/attachment'; + const attach=(token=ta,body=input,target=path)=>request(token,target,'PATCH',body); + let reviewUnit; + try{ + check((await attach()).status===409,'Registered license attachment must be immutable'); + await db.administrativeRecord.update({where:{id:record.id},data:{status:'DRAFT'}}); + check((await request(ta,path)).status===404,'Missing attachment returns 404'); + check((await attach(tb)).status===404,'Foreign center cannot attach'); + check((await request(tb,path)).status===404,'Foreign center cannot download'); + check((await attach(ts)).status===403,'Ordinary staff cannot attach'); + check((await request(ts,path)).status===403,'Ordinary staff cannot download'); + check((await request(undefined,path,'PATCH',input)).status===401,'Guest cannot attach'); + check((await request(undefined,path)).status===401,'Guest cannot download'); + check((await attach(ta,input,'/center/me/licenses/'+memo.id+'/attachment')).status===404,'Internal memo is not a license'); + for(const invalid of [{base64:'!!!!'},{mimeType:'text/html'},{base64:Buffer.from('%PDF-not-a-document').toString('base64')},{issuedAt:'2025-02-30'},{expiresAt:'2024-01-01'},{expiresAt:null},{base64:Buffer.alloc(2_000_001).toString('base64')}]){ + check((await attach(ta,{...input,...invalid})).status===400,'Malformed license data must be rejected: '+Object.keys(invalid)[0]); + } + check(await db.auditEvent.count({where:{resourceId:record.id,action:'CENTER_LICENSE_ATTACHED'}})===0,'Denied upload must not audit success'); + let r=await attach();check(r.status===200,'Draft license upload succeeds');const attached=await r.json(); + check(!('content' in attached)&&!('base64' in attached),'Upload response excludes bytes'); + const saved=await db.organizationDocumentAsset.findUniqueOrThrow({where:{id:attached.licenseAssetId}}); + check(saved.kind==='LICENSE_ATTACHMENT'&&saved.organizationId===a.org.id&&saved.sha256===createHash('sha256').update(bytes).digest('hex'),'Persisted file kind, center and digest match'); + const audit=await db.auditEvent.findFirst({where:{resourceId:record.id,action:'CENTER_LICENSE_ATTACHED'}}); + check(audit?.actorId===ownerA.personId&&audit.metadata.assetId===saved.id,'Attachment audit links actor and asset'); + r=await request(ta,path);check(r.status===200&&r.headers.get('cache-control')==='private, no-store'&&r.headers.get('content-disposition').startsWith('attachment;'),'Download is private and attachment-only'); + check(Buffer.from(await r.arrayBuffer()).equals(bytes),'Download round-trips exact bytes'); + await db.organizationDocumentAsset.update({where:{id:saved.id},data:{content:Buffer.from('tampered')}}); + check((await request(ta,path)).status===409,'Tampered file is not downloaded'); + await db.organizationDocumentAsset.update({where:{id:saved.id},data:{content:bytes}}); + await db.roleAssignment.update({where:{accountId_role:{accountId:ownerA.id,role:'DIVE_CENTER'}},data:{status:'SUSPENDED'}}); + check((await attach()).status===403&&(await request(ta,path)).status===403,'Revoked center role denies both new routes immediately'); + await db.roleAssignment.update({where:{accountId_role:{accountId:ownerA.id,role:'DIVE_CENTER'}},data:{status:'ACTIVE'}}); + check((await attach()).status===200,'Restored role can replace draft attachment'); + check(await db.organizationDocumentAsset.count({where:{organizationId:a.org.id,sha256:saved.sha256}})===1,'Identical file deduplicates within center'); + r=await request(ta,`/administrative-affairs/records/${record.id}/register`,'PATCH');check(r.status===200,'Existing administrative registration freezes attachment'); + check((await attach(ta,{...input,expiresAt:'2031-01-01'})).status===409,'Dates cannot change after registration'); + const stored=await db.administrativeRecord.findUniqueOrThrow({where:{id:record.id}}); + check(stored.licenseExpiresAt.toISOString().startsWith('2030-01-01'),'Denied replacement preserves expiry'); + reviewUnit=await db.orgUnit.create({data:{organizationId:a.org.id,type:'DEPARTMENT',code:'LICENSE-REVIEW-'+randomUUID(),nameAr:'مراجعة داخلية'}}); + r=await request(ta,`/administrative-affairs/records/${record.id}/routings`,'POST',{toUnitId:reviewUnit.id});check(r.status===201,'License uses existing internal routing');const routing=await r.json(); + await db.organizationMember.update({where:{organizationId_accountId:{organizationId:a.org.id,accountId:staff.id}},data:{role:'ADMIN'}}); + r=await request(ta,`/administrative-affairs/routings/${routing.id}/assign`,'PATCH',{assigneeAccountId:staff.id});check(r.status===200,'Separate eligible reviewer can be assigned'); + r=await request(ta,`/administrative-affairs/routings/${routing.id}/decision`,'PATCH',{decision:'APPROVE'});check(r.status===403,'Requester cannot decide assigned review'); + r=await request(ts,`/administrative-affairs/routings/${routing.id}/decision`,'PATCH',{decision:'APPROVE'});check(r.status===200,'Assigned independent reviewer can decide'); + const listed=await request(ta,'/center/me/documents');const view=(await listed.json()).licenses.find(x=>x.id===record.id); + check(view.licenseAssetId===saved.id&&view.licenseExpiresAt.startsWith('2030-01-01')&&view.routings[0].decision==='APPROVE','Center list links attachment, validity and internal review'); + check(!JSON.stringify(view).includes(input.base64),'License list excludes file bytes'); + }finally{ + await db.organizationMember.update({where:{organizationId_accountId:{organizationId:a.org.id,accountId:staff.id}},data:{role:'STAFF'}}); + await db.administrativeRouting.deleteMany({where:{recordId:record.id}}); + if(reviewUnit)await db.orgUnit.delete({where:{id:reviewUnit.id}}); + await db.auditEvent.deleteMany({where:{actorId:{in:[ownerA.personId,staff.personId]},action:{in:['CENTER_LICENSE_ATTACHED','ADMIN_RECORD_REGISTERED','ADMIN_ROUTING_CREATED','ADMIN_ROUTING_ASSIGNED','ADMIN_ROUTING_APPROVE']}}}); + } +} diff --git a/apps/api/scripts/center-license-migration-db-e2e.mjs b/apps/api/scripts/center-license-migration-db-e2e.mjs new file mode 100644 index 000000000..3f62a3a10 --- /dev/null +++ b/apps/api/scripts/center-license-migration-db-e2e.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import {PrismaClient} from '@prisma/client'; +import {randomUUID} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +const url=new URL(process.env.DATABASE_URL); +if(process.env.CI!=='true'||!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('License migration test requires CI loopback database'); +const root=new PrismaClient(); +const sql=await readFile(new URL('../prisma/migrations/20261003080000_center_license_attachments/migration.sql',import.meta.url),'utf8'); +const boundary=sql.indexOf('END $$;')+'END $$;'.length; +try{ + for(const type of ['text','uuid']){ + const schema='license_'+randomUUID().replaceAll('-',''); + await root.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`); + const scoped=new URL(url);scoped.searchParams.set('schema',schema); + const db=new PrismaClient({datasourceUrl:scoped.toString()}); + try{ + await db.$executeRawUnsafe(`CREATE TABLE "OrganizationDocumentAsset" ("id" ${type} PRIMARY KEY)`); + await db.$executeRawUnsafe('CREATE TABLE "AdministrativeRecord" ("id" text PRIMARY KEY)'); + await db.$executeRawUnsafe(sql.slice(0,boundary));await db.$executeRawUnsafe(sql.slice(boundary)); + const [column]=await db.$queryRaw`SELECT data_type FROM information_schema.columns WHERE table_schema=${schema} AND table_name='AdministrativeRecord' AND column_name='licenseAssetId'`; + assert.equal(column.data_type,type); + const asset=randomUUID(); + await db.$executeRawUnsafe(`INSERT INTO "OrganizationDocumentAsset" VALUES ($1::${type})`,asset); + await db.$executeRawUnsafe(`INSERT INTO "AdministrativeRecord" VALUES ('valid',$1::${type},'2025-01-01','2030-01-01')`,asset); + await assert.rejects(()=>db.$executeRawUnsafe(`INSERT INTO "AdministrativeRecord" VALUES ('foreign',$1::${type},'2025-01-01','2030-01-01')`,randomUUID())); + await assert.rejects(()=>db.$executeRawUnsafe(`INSERT INTO "AdministrativeRecord" VALUES ('dates',$1::${type},'2030-01-01','2025-01-01')`,asset)); + await assert.rejects(()=>db.$executeRawUnsafe(`DELETE FROM "OrganizationDocumentAsset" WHERE "id"=$1::${type}`,asset)); + await db.$executeRawUnsafe(`INSERT INTO "AdministrativeRecord" ("id") VALUES ('legacy')`); + }finally{await db.$disconnect();await root.$executeRawUnsafe(`DROP SCHEMA "${schema}" CASCADE`);} + } + console.log('Center license forward migration passed: TEXT/UUID asset keys, FK integrity, expiry constraint, delete restriction and nullable legacy records.'); +}finally{await root.$disconnect();} diff --git a/apps/api/scripts/center-license-registration-race-checks.mjs b/apps/api/scripts/center-license-registration-race-checks.mjs new file mode 100644 index 000000000..05471ae7c --- /dev/null +++ b/apps/api/scripts/center-license-registration-race-checks.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {PDFDocument} from 'pdf-lib'; +import {DatabaseService} from '../dist/database/database.service.js'; +import {AuditService} from '../dist/audit/audit.service.js'; +import {CenterLicenseService} from '../dist/organizations/center-license.service.js'; +import {AdministrativeAffairsPersistenceService} from '../dist/administrative-affairs/administrative-affairs-persistence.service.js'; + +// Commit an HTTP attachment edit at a deterministic service boundary. The API +// process uses its own database client, so its edit is genuinely committed +// before the registration transaction starts; no timing-dependent sleeps. +export async function checkCenterLicenseRegistrationRace({base,a,ownerA,ta},check){ + const loopback=host=>['localhost','127.0.0.1','[::1]'].includes(host); + if(process.env.CI!=='true'||!loopback(new URL(base).hostname)||!loopback(new URL(process.env.DATABASE_URL).hostname))throw new Error('License race fixtures require CI and loopback API/PostgreSQL'); + const db=new DatabaseService(); + const originalTransaction=db.$transaction.bind(db); + const ids=[]; + const request=async(path,method,body)=>{ + const response=await fetch(base+'/center/me/licenses'+path,{method,headers:{authorization:'Bearer '+ta,'content-type':'application/json'},body:JSON.stringify(body)}); + return {status:response.status,body:await response.json()}; + }; + try{ + const source=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'LICENSE',licenseAssetId:{not:null}}}); + const file=await db.organizationDocumentAsset.findUniqueOrThrow({where:{id:source.licenseAssetId}}); + const pdf=await PDFDocument.create();pdf.addPage();pdf.setTitle('Concurrent license '+randomUUID()); + const replacement=Buffer.from(await pdf.save()).toString('base64'); + for(const scenario of ['attachment-before-canonical-read','dates-before-transaction']){ + const created=await request('','POST',{type:'LICENSE',unitId:source.unitId,referenceNumber:'RACE-'+randomUUID(),subject:'License registration race'}); + assert.equal(created.status,201);const id=created.body.id;ids.push(id); + const initial={mimeType:file.mimeType,base64:Buffer.from(file.content).toString('base64'),issuedAt:'2025-01-01',expiresAt:'2030-01-01'}; + assert.equal((await request('/'+id+'/attachment','PATCH',initial)).status,200); + const before=await db.administrativeRecord.findUniqueOrThrow({where:{id}}); + const administrative=new AdministrativeAffairsPersistenceService(db); + const canonicalRegister=administrative.registerRecord.bind(administrative); + const service=new CenterLicenseService(db,new AuditService(db),administrative); + let interleavings=0; + const revise=async()=>{ + interleavings++; + const changed=scenario.startsWith('attachment')?{...initial,mimeType:'application/pdf',base64:replacement}:{...initial,expiresAt:'2031-01-01'}; + assert.equal((await request('/'+id+'/attachment','PATCH',changed)).status,200); + // Exercise equal timestamp precision as well: attachment/date predicates + // must still reject a changed draft when updatedAt happens to match. + await db.administrativeRecord.update({where:{id},data:{updatedAt:before.updatedAt}}); + }; + if(scenario.startsWith('attachment'))administrative.registerRecord=async(...args)=>{await revise();return canonicalRegister(...args);}; + else db.$transaction=async(...args)=>{await revise();return originalTransaction(...args);}; + try{ + await assert.rejects(()=>service.register(ownerA.id,a.org.id,id),error=>error.getStatus?.()===409&&error.message==='ADMIN_RECORD_CONCURRENT_MODIFICATION'); + check(interleavings===1,scenario+': committed edit rejects stale registration'); + }finally{db.$transaction=originalTransaction;administrative.registerRecord=canonicalRegister;} + const after=await db.administrativeRecord.findUniqueOrThrow({where:{id}}); + check(after.status==='DRAFT',scenario+': failed registration leaves draft editable'); + check(scenario.startsWith('attachment')?after.licenseAssetId!==before.licenseAssetId:after.licenseExpiresAt.toISOString().startsWith('2031-01-01'),scenario+': concurrent edit is preserved'); + check(await db.auditEvent.count({where:{resourceId:id,action:'ADMIN_RECORD_REGISTERED'}})===0,scenario+': rejected registration creates no audit success'); + check((await request('/'+id+'/register','PATCH',{})).status===200,scenario+': fresh HTTP registration succeeds'); + check(await db.auditEvent.count({where:{resourceId:id,action:'ADMIN_RECORD_REGISTERED'}})===1,scenario+': exactly one canonical registration audit'); + } + }finally{ + db.$transaction=originalTransaction; + await db.auditEvent.deleteMany({where:{resourceId:{in:ids}}}); + await db.administrativeRecord.deleteMany({where:{id:{in:ids}}}); + await db.$disconnect(); + } +} diff --git a/apps/api/scripts/center-license-review-http-checks.mjs b/apps/api/scripts/center-license-review-http-checks.mjs new file mode 100644 index 000000000..80359947c --- /dev/null +++ b/apps/api/scripts/center-license-review-http-checks.mjs @@ -0,0 +1,71 @@ +import {randomUUID} from 'node:crypto'; +export async function checkCenterLicenseReview(db,{base,a,b,ownerA,staff,ta,tb,ts},check){ + const source=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'LICENSE',licenseAssetId:{not:null}}}); + const file=await db.organizationDocumentAsset.findUniqueOrThrow({where:{id:source.licenseAssetId}}); + const foreignUnit=await db.orgUnit.findFirstOrThrow({where:{organizationId:b.org.id}}); + const memo=await db.administrativeRecord.findFirstOrThrow({where:{organizationId:a.org.id,type:'INTERNAL_MEMO'}}); + const ids=[];let recordId,unit; + const request=async(token,path,method='GET',body)=>{ + const r=await fetch(base+'/center/me'+path,{method,headers:{...(token?{authorization:'Bearer '+token}:{}),...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}); + return{status:r.status,body:await r.json()}; + }; + const view=async token=>(await request(token,'/documents')).body.licenses.find(x=>x.id===recordId); + try{ + let r=await request(ta,'/licenses','POST',{type:'LICENSE',unitId:source.unitId,referenceNumber:'REVIEW-'+randomUUID(),subject:'اختبار المراجعة الداخلية'}); + check(r.status===201,'Review fixture is created through center API');recordId=r.body.id;ids.push(recordId); + const register='/licenses/'+recordId+'/register'; + check((await request(ta,register,'PATCH',{})).status===409,'Registration requires linked license attachment and dates'); + check((await request(tb,register,'PATCH',{})).status===404,'Foreign manager cannot register license'); + check((await request(ts,register,'PATCH',{})).status===403,'Ordinary staff cannot register'); + check((await request(undefined,register,'PATCH',{})).status===401,'Guest cannot register'); + check((await request(ta,'/licenses/'+memo.id+'/register','PATCH',{})).status===404,'Non-regulatory record cannot enter center license workflow'); + unit=await db.orgUnit.create({data:{organizationId:a.org.id,type:'DEPARTMENT',code:'REVIEW-'+randomUUID(),nameAr:'مراجعة الرخص'}}); + const route='/licenses/'+recordId+'/reviews'; + check((await request(ta,route,'POST',{toUnitId:unit.id})).status===409,'Draft cannot bypass registration'); + r=await request(ta,'/licenses/'+recordId+'/attachment','PATCH',{mimeType:file.mimeType,base64:Buffer.from(file.content).toString('base64'),issuedAt:'2025-01-01',expiresAt:'2030-01-01'});check(r.status===200,'Draft attachment is uploaded'); + check((await request(ta,register,'PATCH',{})).status===200,'Attached draft can register through center wrapper'); + check((await request(ta,register,'PATCH',{})).status===409,'Registration is not repeated'); + check((await request(ta,route,'POST',{toUnitId:source.unitId})).status===400,'Review must use a distinct unit'); + check((await request(ta,route,'POST',{toUnitId:foreignUnit.id})).status===404,'Foreign review unit rejected'); + await db.orgUnit.update({where:{id:unit.id},data:{active:false}}); + check((await request(ta,route,'POST',{toUnitId:unit.id})).status===404,'Inactive review unit rejected'); + await db.orgUnit.update({where:{id:unit.id},data:{active:true}}); + r=await request(ta,route,'POST',{toUnitId:unit.id});check(r.status===201,'Registered license is routed');const routingId=r.body.id;ids.push(routingId); + const assign='/license-reviews/'+routingId+'/assign',decide='/license-reviews/'+routingId+'/decision'; + let row=await view(ta);check(row.reviewUnits.some(x=>x.id===unit.id)&&!row.reviewUnits.some(x=>x.id===source.unitId),'Review destination choices exclude source unit'); + check(!row.routings[0].reviewerOptions.some(x=>x.id===ownerA.id)&&!row.routings[0].canDecide,'Requester is never offered as own reviewer'); + check((await request(tb,assign,'PATCH',{assigneeAccountId:staff.id})).status===404,'Foreign manager cannot assign review'); + check((await request(tb,decide,'PATCH',{decision:'APPROVE'})).status===404,'Foreign manager cannot decide review'); + check((await request(ta,assign,'PATCH',{assigneeAccountId:ownerA.id})).status===403,'Requester self-assignment rejected'); + check((await request(ta,assign,'PATCH',{assigneeAccountId:staff.id})).status===403,'Ordinary staff excluded from center portal reviewer choices'); + await db.organizationMember.update({where:{organizationId_accountId:{organizationId:a.org.id,accountId:staff.id}},data:{role:'ADMIN'}}); + await db.roleAssignment.update({where:{accountId_role:{accountId:staff.id,role:'DIVE_CENTER'}},data:{status:'SUSPENDED'}}); + check((await request(ta,assign,'PATCH',{assigneeAccountId:staff.id})).status===403,'Suspended center role cannot be assigned'); + await db.roleAssignment.update({where:{accountId_role:{accountId:staff.id,role:'DIVE_CENTER'}},data:{status:'ACTIVE'}}); + row=await view(ta);check(row.routings[0].reviewerOptions.some(x=>x.id===staff.id),'Independent active manager appears as reviewer'); + check((await request(ta,assign,'PATCH',{assigneeAccountId:staff.id})).status===200,'Independent reviewer assigned through center wrapper'); + check((await request(ta,decide,'PATCH',{decision:'APPROVE'})).status===403,'Requester cannot record assigned reviewer decision'); + row=await view(ts);check(row.routings[0].canDecide&&!!row.routings[0].reviewerName,'Assigned reviewer sees decision control and display name'); + check(!('requestedByAccountId' in row.routings[0])&&!('assignedToAccountId' in row.routings[0]),'Review metadata omits raw requester/assignee fields'); + check((await request(ts,decide,'PATCH',{decision:'INVALID'})).status===400,'Invalid decision rejected'); + check((await request(ts,decide,'PATCH',{decision:'APPROVE'})).status===200,'Assigned independent reviewer can approve'); + check((await request(ts,decide,'PATCH',{decision:'REJECT'})).status===409,'Final decision cannot be overwritten'); + check((await request(ta,assign,'PATCH',{assigneeAccountId:staff.id})).status===409,'Decided review cannot be reassigned'); + row=await view(ts);check(!row.routings[0].canDecide&&row.routings[0].reviewerOptions.length===0,'Final review exposes no decision or assignment controls'); + r=await request(ta,route,'POST',{toUnitId:unit.id});check(r.status===201,'Additional internal review has its own routing record');const second=r.body.id;ids.push(second); + check((await request(ta,'/license-reviews/'+second+'/assign','PATCH',{assigneeAccountId:staff.id})).status===200,'Second review independently assigned'); + check((await request(ts,'/license-reviews/'+second+'/decision','PATCH',{decision:'REJECT'})).status===200,'Reviewer can reject through the same guarded workflow'); + row=await view(ta);check(row.routings.length===2&&row.routings[0].decision==='REJECT'&&row.routings[1].decision==='APPROVE','Both decisions remain visible, latest first'); + const historicalReviewer=row.routings[0].reviewerName; + await db.roleAssignment.update({where:{accountId_role:{accountId:staff.id,role:'DIVE_CENTER'}},data:{status:'SUSPENDED'}}); + row=await view(ta);check(!!historicalReviewer&&row.routings.every(x=>x.reviewerName===historicalReviewer),'Historical reviewer names survive later role revocation'); + const audits=await db.auditEvent.findMany({where:{resourceId:{in:ids}}}); + check(audits.some(x=>x.action==='ADMIN_ROUTING_APPROVE'&&x.actorId===staff.personId)&&audits.some(x=>x.action==='ADMIN_ROUTING_REJECT'&&x.actorId===staff.personId),'Decisions retain canonical reviewer audit events'); + }finally{ + await db.roleAssignment.update({where:{accountId_role:{accountId:staff.id,role:'DIVE_CENTER'}},data:{status:'ACTIVE'}}); + await db.organizationMember.update({where:{organizationId_accountId:{organizationId:a.org.id,accountId:staff.id}},data:{role:'STAFF'}}); + await db.auditEvent.deleteMany({where:{resourceId:{in:ids}}}); + if(recordId){await db.administrativeRouting.deleteMany({where:{recordId}});await db.administrativeRecord.delete({where:{id:recordId}});} + if(unit)await db.orgUnit.delete({where:{id:unit.id}}); + } +} diff --git a/apps/api/scripts/center-safety-http-e2e.mjs b/apps/api/scripts/center-safety-http-e2e.mjs new file mode 100644 index 000000000..89dec3ea0 --- /dev/null +++ b/apps/api/scripts/center-safety-http-e2e.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import {checkCenterLicenseRegistrationRace} from './center-license-registration-race-checks.mjs'; +import {checkCenterLicenseReview} from './center-license-review-http-checks.mjs'; +import {checkCenterLicenseCreation} from './center-license-create-http-checks.mjs'; +import {checkCenterLicenses} from './center-license-http-checks.mjs'; +import {checkCenterEquipment} from './center-equipment-http-checks.mjs'; +import {PrismaClient} from '@prisma/client'; +import {createHash,createHmac,randomUUID,randomBytes} from 'node:crypto'; + +const base=process.env.CENTER_SAFETY_E2E_BASE_URL||'http://127.0.0.1:3101/api/v1'; +const databaseUrl=process.env.DATABASE_URL; +const secret=process.env.JWT_SECRET; +const loopback=host=>['127.0.0.1','localhost','[::1]'].includes(host); +if(process.env.CI!=='true'||!databaseUrl||!secret||!loopback(new URL(base).hostname)||!loopback(new URL(databaseUrl).hostname))throw new Error('Center safety fixtures require CI and loopback API/PostgreSQL. Never run against production.'); +const db=new PrismaClient(),tag=randomUUID(); +const ids={people:[],accounts:[],organizations:[],trips:[]}; +let checks=0; +const check=(condition,message)=>{assert.ok(condition,message);checks++;}; +const personAccount=async label=>{ + const person=await db.person.create({data:{firstName:'CenterSafety',lastName:label}});ids.people.push(person.id); + const account=await db.account.create({data:{personId:person.id,email:`center-safety-${label}-${tag}@example.invalid`,passwordHash:'ci-fixture',status:'ACTIVE',emailVerifiedAt:new Date()}});ids.accounts.push(account.id); + await db.roleAssignment.create({data:{accountId:account.id,role:'DIVE_CENTER',status:'ACTIVE',activeAt:new Date(),scope:{purpose:'CENTER_SAFETY_CI'}}}); + return account; +}; +const tokenFor=async accountId=>{ + const session=await db.session.create({data:{accountId,tokenHash:randomBytes(32).toString('hex'),expiresAt:new Date(Date.now()+3600000)}}); + const encode=value=>Buffer.from(JSON.stringify(value)).toString('base64url'),now=Math.floor(Date.now()/1000); + const body=encode({alg:'HS256',typ:'JWT'})+'.'+encode({sub:accountId,sid:session.id,iat:now,exp:now+900}); + return body+'.'+createHmac('sha256',secret).update(body).digest('base64url'); +}; +const read=async(token,path='/me/safety',method='GET')=>{ + const response=await fetch(base+'/center'+path,{ + method,headers:{...(token?{authorization:'Bearer '+token}:{}),...(method==='PATCH'?{'content-type':'application/json'}:{})}, + ...(method==='PATCH'?{body:JSON.stringify({movementType:'TRANSFER',toLocation:'CI-DENIED'})}:{}), + }); + return {status:response.status,body:await response.json().catch(()=>null)}; +}; +const centerFor=async owner=>{ + const org=await db.organization.create({data:{displayName:'Center safety CI '+owner.id,kind:'DIVE_CENTER',status:'ACTIVE',ownerId:owner.id}});ids.organizations.push(org.id); + const member=await db.organizationMember.create({data:{organizationId:org.id,accountId:owner.id,role:'OWNER',status:'ACTIVE'}}); + const trip=await db.trip.create({data:{organizationId:org.id,title:'Scoped trip '+org.id,type:'BOAT',capacity:4,status:'OPEN',startsAt:new Date('2030-01-01T07:00:00Z'),endsAt:new Date('2030-01-01T11:00:00Z')}});ids.trips.push(trip.id); + const checklist=await db.safetyChecklist.create({data:{tripId:trip.id,items:{weather_review:true},decision:'REVIEW_REQUIRED',notes:'CI scope fixture'}}); + const incident=await db.safetyIncident.create({data:{tripId:trip.id,reportedByAccountId:owner.id,severity:'LOW',title:'Incident '+org.id,description:'Private reporter narrative must not be returned'}}); + return {org,member,trip,checklist,incident}; +}; +try{ + const ownerA=await personAccount('owner-a'),ownerB=await personAccount('owner-b'),staff=await personAccount('staff'); + const a=await centerFor(ownerA),b=await centerFor(ownerB); + await db.organizationMember.create({data:{organizationId:a.org.id,accountId:staff.id,role:'STAFF',status:'ACTIVE'}}); + const unlinked=await db.safetyIncident.create({data:{reportedByAccountId:ownerA.id,severity:'LOW',title:'Unlinked incident',description:'Must not appear in any center view'}}); + const [ta,tb,ts]=await Promise.all([tokenFor(ownerA.id),tokenFor(ownerB.id),tokenFor(staff.id)]); + check((await read()).status===401,'Anonymous read must be rejected'); + let r=await read(ta);check(r.status===200,'Active center owner must read safety'); + check(r.body.checklists.length===1&&r.body.checklists[0].id===a.checklist.id,'Center A checklist isolation'); + check(r.body.incidents.length===1&&r.body.incidents[0].id===a.incident.id,'Center A incident isolation'); + check(r.body.checklists[0].updatedAt&&r.body.checklists[0].trip.title===a.trip.title,'Checklist must provide UI date and trip fields'); + check(!r.body.incidents.some(item=>item.id===unlinked.id),'Unlinked incident must remain outside center scope'); + for(const field of ['reportedByAccountId','resolvedByAccountId','description','resolutionNotes'])check(!(field in r.body.incidents[0]),'Do not expose private incident field '+field); + r=await read(tb);check(r.status===200&&r.body.incidents.length===1&&r.body.incidents[0].id===b.incident.id,'Center B must not receive center A incident'); + check(r.body.checklists.length===1&&r.body.checklists[0].id===b.checklist.id,'Center B checklist isolation'); + check((await read(ts)).status===403,'An ordinary staff membership must not grant manager access'); + await db.organizationMember.update({where:{id:a.member.id},data:{status:'SUSPENDED'}}); + check((await read(ta)).status===403,'Suspended manager membership must be denied immediately'); + await db.organizationMember.update({where:{id:a.member.id},data:{status:'ACTIVE'}}); + await db.organization.update({where:{id:a.org.id},data:{status:'SUSPENDED'}}); + check((await read(ta)).status===403,'Suspended center must be denied even with an active manager'); + await db.organization.update({where:{id:a.org.id},data:{status:'ACTIVE'}}); + r=await read(ta);check(r.status===200&&r.body.incidents[0].id===a.incident.id,'Restored authority must recover without widening scope'); + + // Keep the organization and manager membership active while changing only the + // portal role. Reuse the same token to prove per-request server enforcement. + const missingResourceId=randomUUID(); + const routes=[ + ['/me/overview'],['/me/safety'],['/me/documents'],['/me/customers'], + ['/me/team'],['/me/professionals'],['/me/trips'],[`/me/trips/${a.trip.id}/bookings`], + [`/me/licenses/${missingResourceId}/register`,'PATCH'],[`/me/licenses/${missingResourceId}/reviews`,'POST'], + [`/me/license-reviews/${missingResourceId}/assign`,'PATCH'],[`/me/license-reviews/${missingResourceId}/decision`,'PATCH'], + ['/me/licenses','POST'],[`/me/licenses/${missingResourceId}/renew`,'POST'], + [`/me/licenses/${missingResourceId}/attachment`],[`/me/licenses/${missingResourceId}/attachment`,'PATCH'], + ['/me/equipment'],[`/me/equipment/lookup/${missingResourceId}`], + [`/me/equipment/${missingResourceId}/history`],[`/me/equipment/${missingResourceId}/move`,'PATCH'], + ]; + await db.roleAssignment.create({data:{accountId:ownerA.id,role:'INSTRUCTOR',status:'ACTIVE'}}); + for(const status of ['DRAFT','PENDING_REVIEW','REJECTED','SUSPENDED','ARCHIVED']){ + await db.roleAssignment.update({where:{accountId_role:{accountId:ownerA.id,role:'DIVE_CENTER'}},data:{status}}); + for(const [path,method] of routes){ + r=await read(ta,path,method); + check(r.status===403,`${status} center role must deny ${method||'GET'} ${path}, got ${r.status}`); + } + } + await db.roleAssignment.delete({where:{accountId_role:{accountId:ownerA.id,role:'DIVE_CENTER'}}}); + for(const [path,method] of routes){ + r=await read(ta,path,method); + check(r.status===403,`An unrelated active role must not replace missing DIVE_CENTER for ${path}, got ${r.status}`); + } + await db.roleAssignment.create({data:{accountId:ownerA.id,role:'DIVE_CENTER',status:'ACTIVE',activeAt:new Date()}}); + for(const [path] of routes.slice(0,8)){ + check((await read(ta,path)).status===200,`Restored center role must recover scoped read ${path}`); + } + + // Review the center document-list contract with real, isolated records. This + // verifies metadata/read scope only; it is not an upload/download acceptance. + const assetFor=async organizationId=>{ + const content=Buffer.from('PRIVATE-CENTER-ASSET:'+organizationId); + return db.organizationDocumentAsset.create({data:{organizationId,kind:'LICENSE',mimeType:'application/pdf',byteSize:content.length,sha256:createHash('sha256').update(content).digest('hex'),content}}); + }; + const assetA=await assetFor(a.org.id),assetB=await assetFor(b.org.id); + const licenseTypes=['LICENSE','PERMIT','CERTIFICATE','REGULATORY_APPROVAL']; + for(const [center,owner] of [[a,ownerA],[b,ownerB]]){ + const unit=await db.orgUnit.create({data:{organizationId:center.org.id,type:'CENTER',code:'CENTER-DOCS',nameAr:'وحدة اختبار المركز'}}); + for(const type of [...licenseTypes,'INTERNAL_MEMO']){ + await db.administrativeRecord.create({data:{organizationId:center.org.id,unitId:unit.id,ownerAccountId:owner.id,type,referenceNumber:`${center.org.id}-${type}`,subject:`${type} ${center.org.id}`,status:'REGISTERED'}}); + } + } + r=await read(ta,'/me/documents'); + check(r.status===200&&r.body.assets.length===1&&r.body.assets[0].id===assetA.id,'Center A must see only its document asset'); + check(!('content' in r.body.assets[0])&&!('organizationId' in r.body.assets[0]),'Document list must not return binary content or unnecessary organization identifiers'); + check(r.body.assets[0].byteSize===assetA.byteSize&&r.body.assets[0].sha256===assetA.sha256,'Document metadata must reflect persisted asset'); + check(r.body.licenses.length===4&&licenseTypes.every(type=>r.body.licenses.some(row=>row.type===type)),'Center document list must include all four regulatory record types and exclude internal memos'); + check(r.body.licenses.every(row=>row.referenceNumber.startsWith(a.org.id)&&row.status==='REGISTERED'),'Center A licenses must retain own reference and status'); + check(r.body.licenses.every(row=>!('ownerAccountId' in row)&&!('unitId' in row)&&!('organizationId' in row)),'License list must minimize administrative identifiers'); + r=await read(tb,'/me/documents'); + check(r.status===200&&r.body.assets.length===1&&r.body.assets[0].id===assetB.id,'Center B must not receive center A document metadata'); + check(r.body.licenses.length===4&&r.body.licenses.every(row=>row.referenceNumber.startsWith(b.org.id)),'Center B license list must remain isolated'); + check((await read(ts,'/me/documents')).status===403,'Ordinary staff must not read center manager documents'); + check((await read(undefined,'/me/documents')).status===401,'Anonymous document listing must be denied'); + + await checkCenterEquipment(db,{base,a,b,ownerA,ta,tb,ts},check); + + await checkCenterLicenses(db,{base,a,b,ownerA,staff,ta,tb,ts},check); + + await checkCenterLicenseCreation(db,{base,a,b,ownerA,ta,tb,ts},check); + + await checkCenterLicenseReview(db,{base,a,b,ownerA,staff,ta,tb,ts},check); + await checkCenterLicenseRegistrationRace({base,a,ownerA,ta},check); + + console.log(`Center safety and portal authorization HTTP/PostgreSQL E2E: ${checks}/${checks} passed (exact active role across 20 routes, same-session revocation/recovery, center isolation document metadata privacy, equipment movements/inspection gates/audit and TEXT/UUID center scope).`); +}finally{ + await db.administrativeRecord.deleteMany({where:{organizationId:{in:ids.organizations}}}); + await db.orgUnit.deleteMany({where:{organizationId:{in:ids.organizations}}}); + await db.organizationDocumentAsset.deleteMany({where:{organizationId:{in:ids.organizations}}}); + await db.safetyIncident.deleteMany({where:{reportedByAccountId:{in:ids.accounts}}}); + await db.safetyChecklist.deleteMany({where:{tripId:{in:ids.trips}}}); + await db.trip.deleteMany({where:{id:{in:ids.trips}}}); + await db.organizationMember.deleteMany({where:{organizationId:{in:ids.organizations}}}); + await db.organization.deleteMany({where:{id:{in:ids.organizations}}}); + await db.roleAssignment.deleteMany({where:{accountId:{in:ids.accounts}}}); + await db.session.deleteMany({where:{accountId:{in:ids.accounts}}}); + await db.account.deleteMany({where:{id:{in:ids.accounts}}}); + await db.person.deleteMany({where:{id:{in:ids.people}}}); + await db.$disconnect(); +} diff --git a/apps/api/scripts/document-http-db-e2e.mjs b/apps/api/scripts/document-http-db-e2e.mjs index b37f51618..2ddb123b8 100644 --- a/apps/api/scripts/document-http-db-e2e.mjs +++ b/apps/api/scripts/document-http-db-e2e.mjs @@ -1,3 +1,4 @@ +import {checkDocumentTransitionRace} from './document-transition-race-db-checks.mjs'; import { PrismaClient } from '@prisma/client'; import { PDFDocument } from 'pdf-lib'; import { createHmac } from 'node:crypto'; @@ -81,6 +82,7 @@ try{ if(rs.some(x=>!x.ok))throw new Error('Concurrent create failed: '+rs.map(x=>x.status).join(',')); const docs=await Promise.all(rs.map(x=>x.json())),refs=new Set(docs.map(x=>x.referenceNumber)); if(refs.size!==docs.length)throw new Error('Concurrent reference numbers not unique'); + await checkDocumentTransitionRace({base,call,creatorToken:token(creator.id),creatorId:creator.id,body:latestBody}); console.log('Document Template Revision validation passed.'); console.log('Document PDF Rendering validation passed.'); console.log('Document HTTP/DB E2E validation passed.'); diff --git a/apps/api/scripts/document-transition-race-db-checks.mjs b/apps/api/scripts/document-transition-race-db-checks.mjs new file mode 100644 index 000000000..5741ef260 --- /dev/null +++ b/apps/api/scripts/document-transition-race-db-checks.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import {DatabaseService} from '../dist/database/database.service.js'; +import {DocumentAuthorizationService} from '../dist/document-forms/document-authorization.service.js'; +import {DocumentPersistenceService} from '../dist/document-forms/document-persistence.service.js'; + +// Deterministic service/PostgreSQL interleaving alongside the HTTP lifecycle +// suite. Revision is committed by the running HTTP API before submit's +// transaction begins, avoiding a timing-dependent parallel-request test. +export async function checkDocumentTransitionRace({base,call,creatorToken,creatorId,body}){ + const loopback=host=>['localhost','127.0.0.1','[::1]'].includes(host); + if(process.env.CI!=='true'||!loopback(new URL(base).hostname)||!loopback(new URL(process.env.DATABASE_URL).hostname))throw new Error('Document race fixtures require CI and loopback API/PostgreSQL'); + const db=new DatabaseService(); + let id; + try{ + const created=await call('/documents','POST',creatorToken,{...body,contentHash:'race-v1',payload:{summary:'original'}}); + assert.equal(created.status,201);id=(await created.json()).id; + const before=await db.managedDocument.findUniqueOrThrow({where:{id}}); + const service=new DocumentPersistenceService(db,new DocumentAuthorizationService(db)); + const serializable=db.serializable.bind(db); + let interleavings=0; + db.serializable=async work=>{ + interleavings++; + const revised=await call(`/documents/${id}/revise`,'POST',creatorToken,{contentHash:'race-v2',payload:{summary:'changed before submit transaction'}}); + assert.equal(revised.status,201); + assert.equal((await revised.json()).version,2); + return serializable(work); + }; + await assert.rejects(()=>service.transition(creatorId,id,'PENDING_APPROVAL'),error=>error.getStatus?.()===400&&/version changed concurrently/.test(error.message)); + db.serializable=serializable; + assert.equal(interleavings,1); + const after=await db.managedDocument.findUniqueOrThrow({where:{id}}); + assert.equal(after.status,'DRAFT'); + assert.equal(after.version,2); + assert.equal(after.contentHash,'race-v2'); + assert.equal(after.referenceNumber,before.referenceNumber); + assert.equal(after.approvedByAccountId,null); + assert.equal(after.signedByAccountId,null); + assert.equal(await db.documentRevision.count({where:{documentId:id}}),2); + assert.equal(await db.documentLifecycleEvent.count({where:{documentId:id}}),2); + assert.equal(await db.documentLifecycleEvent.count({where:{documentId:id,action:'PENDING_APPROVAL'}}),0); + const retry=await call(`/documents/${id}/submit`,'POST',creatorToken); + assert.equal(retry.status,201); + assert.equal((await retry.json()).version,2); + const events=await db.documentLifecycleEvent.findMany({where:{documentId:id,action:'PENDING_APPROVAL'}}); + assert.equal(events.length,1); + assert.equal(events[0].version,2); + console.log('Document transition race PostgreSQL check passed: committed concurrent revision rejects stale submit without transition event; fresh HTTP submit succeeds on version 2.'); + }finally{ + if(id){ + await db.documentLifecycleEvent.deleteMany({where:{documentId:id}}); + await db.documentRevision.deleteMany({where:{documentId:id}}); + await db.managedDocument.delete({where:{id}}); + } + await db.$disconnect(); + } +} diff --git a/apps/api/scripts/phase11-production-security-validation.mjs b/apps/api/scripts/phase11-production-security-validation.mjs index 9dcea01f1..b574df55c 100644 --- a/apps/api/scripts/phase11-production-security-validation.mjs +++ b/apps/api/scripts/phase11-production-security-validation.mjs @@ -6,7 +6,7 @@ const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../..') const read=relative=>readFile(path.join(root,relative),'utf8'); const requireToken=(source,token,label)=>{if(!source.includes(token))throw new Error(`Phase 11 control missing (${label}): ${token}`)}; -const [rootDocker,apiDocker,compose,envTemplate,main,securityWorkflow,mfaService,totp]=await Promise.all([ +const [rootDocker,apiDocker,compose,envTemplate,main,securityWorkflow,mfaService,totp,auditValidator,mobileSecurity]=await Promise.all([ read('Dockerfile'), read('apps/api/Dockerfile'), read('infra/docker-compose.production.yml'), @@ -15,6 +15,8 @@ const [rootDocker,apiDocker,compose,envTemplate,main,securityWorkflow,mfaService read('.github/workflows/security-audit.yml'), read('apps/api/src/auth/mfa.service.ts'), read('apps/api/src/auth/totp.ts'), + read('scripts/release-dependency-audit.mjs'), + read('.github/workflows/mobile-security.yml'), ]); requireToken(rootDocker,'USER node','root production image runs non-root'); @@ -40,8 +42,11 @@ for(const token of ['Strict-Transport-Security','X-Content-Type-Options','X-Fram } requireToken(mfaService,'MfaService','MFA service exists'); requireToken(totp,'verifyTotp','TOTP verification exists'); -requireToken(securityWorkflow,"['high','critical']",'dependency severity gate'); -requireToken(securityWorkflow,'process.exit(1)','security audit fails closed'); +requireToken(auditValidator,"['high','critical']",'dependency severity gate'); +requireToken(auditValidator,'process.exitCode=1','security audit fails closed'); requireToken(securityWorkflow,'if: always()','audit evidence retained on failure'); +requireToken(securityWorkflow,'web-api audit.json','web/API release audit scope'); +requireToken(mobileSecurity,'mobile mobile-audit.json','independent mobile release gate'); +requireToken(mobileSecurity,'repository repository-audit.json','full repository audit retained'); console.log('Phase 11 repository controls validated: non-root container, read-only/no-new-privileges baseline, private DB topology, secret template, HTTP headers, MFA and fail-closed dependency audit.'); diff --git a/apps/api/scripts/runtime-dependency-boundary.mjs b/apps/api/scripts/runtime-dependency-boundary.mjs new file mode 100644 index 000000000..194c2f5b5 --- /dev/null +++ b/apps/api/scripts/runtime-dependency-boundary.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createRequire} from 'node:module'; + +// Inspect installed files, not the monorepo lockfile (which intentionally +// continues to include and audit mobile). Run inside the final API image. +const root=process.cwd(),packages=[],seen=new Set(); +async function scan(directory){ + for(const entry of await fs.readdir(directory,{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;})){ + if(entry.name.startsWith('.'))continue; + const location=path.join(directory,entry.name); + if(entry.name.startsWith('@')){await scan(location);continue;} + const real=await fs.realpath(location); + if(seen.has(real))continue;seen.add(real); + const manifest=JSON.parse(await fs.readFile(path.join(real,'package.json'),'utf8')); + packages.push({name:manifest.name,version:manifest.version,path:path.relative(root,location)}); + await scan(path.join(real,'node_modules')); + } +} +await scan(path.join(root,'node_modules')); +await scan(path.join(root,'apps/api/node_modules')); +assert.ok(packages.length>0,'Runtime package inventory must not be empty'); +// TypeScript can remain as Prisma's optional runtime peer; Nest CLI and +// mobile/browser tooling have no API runtime role. +const forbidden=/^(?:node-forge|braces|@hydroland\/(?:mobile|web)|@nestjs\/cli|@playwright\/.*|playwright(?:-.*)?|@expo\/.*|expo(?:-.*)?|@react-native\/.*|react-native(?:-.*)?|metro(?:-.*)?)$/; +assert.deepEqual(packages.filter(p=>forbidden.test(p.name)),[],'Mobile/web/build-only dependencies must not enter the API image'); +for(const name of ['@nestjs/core','@prisma/client','prisma','pdf-lib','google-auth-library'])assert.ok(packages.some(p=>p.name===name),'Required runtime package missing: '+name); +const require=createRequire(path.join(root,'apps/api/package.json')); +require('reflect-metadata'); +require(path.join(root,'apps/api/dist/app.module.js')); +const {PrismaClient}=require('@prisma/client'); +const client=new PrismaClient();await client.$disconnect(); +console.log(JSON.stringify({boundary:'API production image',count:packages.length,packages:packages.sort((a,b)=>a.name.localeCompare(b.name))},null,2)); diff --git a/apps/api/src/administrative-affairs/administrative-affairs-persistence.service.ts b/apps/api/src/administrative-affairs/administrative-affairs-persistence.service.ts index 809262be9..2de8190f9 100644 --- a/apps/api/src/administrative-affairs/administrative-affairs-persistence.service.ts +++ b/apps/api/src/administrative-affairs/administrative-affairs-persistence.service.ts @@ -49,11 +49,11 @@ export class AdministrativeAffairsPersistenceService { if(!eligible) throw new ForbiddenException('ADMIN_ASSIGNEE_NOT_ELIGIBLE'); } - async registerRecord(recordId:string, actorAccountId:string) { + async registerRecord(recordId:string, actorAccountId:string, expectedLicense?:{updatedAt:Date;licenseAssetId:string|null;licenseIssuedAt:Date|null;licenseExpiresAt:Date|null}) { const record=await this.db.administrativeRecord.findUniqueOrThrow({where:{id:recordId},select:{id:true,organizationId:true,unitId:true,status:true}}); await this.assertPermission(actorAccountId,record.organizationId,'REGISTER',[record.unitId]); const result=await this.db.$transaction(async tx=>{ - const updated=await tx.administrativeRecord.updateMany({where:{id:record.id,status:'DRAFT'},data:{status:'REGISTERED'}}); + const updated=await tx.administrativeRecord.updateMany({where:{id:record.id,status:'DRAFT',...expectedLicense},data:{status:'REGISTERED'}}); if(updated.count!==1) throw new ConflictException('ADMIN_RECORD_CONCURRENT_MODIFICATION'); const actor=await tx.account.findUniqueOrThrow({where:{id:actorAccountId},select:{personId:true}}); await tx.auditEvent.create({data:{actorId:actor.personId,action:'ADMIN_RECORD_REGISTERED',resource:'AdministrativeRecord',resourceId:record.id,metadata:{organizationId:record.organizationId}}}); diff --git a/apps/api/src/document-forms/document-persistence.service.ts b/apps/api/src/document-forms/document-persistence.service.ts index c373956c6..c600ad24d 100644 --- a/apps/api/src/document-forms/document-persistence.service.ts +++ b/apps/api/src/document-forms/document-persistence.service.ts @@ -96,12 +96,12 @@ export class DocumentPersistenceService { return this.db.serializable(async tx=>{ const current=await tx.managedDocument.findUnique({where:{id}}); if(!current) throw new NotFoundException('Document not found.'); - if(current.status!==d.status) throw new BadRequestException('Document status changed concurrently; retry transition.'); + if(current.status!==d.status||current.version!==d.version) throw new BadRequestException('Document status or version changed concurrently; reload before transition.'); const data:any={status:to}; if(to==='APPROVED'){data.approvedByAccountId=accountId;data.approvedAt=new Date();} if(to==='SIGNED'){data.signedByAccountId=accountId;data.signedAt=new Date();} if(to==='ARCHIVED'){data.archivedByAccountId=accountId;data.archivedAt=new Date();} - const claimed=await tx.managedDocument.updateMany({where:{id,status:d.status},data}); + const claimed=await tx.managedDocument.updateMany({where:{id,status:d.status,version:d.version},data}); if(claimed.count!==1) throw new BadRequestException('Document status changed concurrently; retry transition.'); const updated=await tx.managedDocument.findUniqueOrThrow({where:{id}}); await tx.documentLifecycleEvent.create({data:{documentId:id,actorAccountId:accountId,action:to,fromStatus:d.status,toStatus:to,version:updated.version}}); diff --git a/apps/api/src/organizations/center-license.service.ts b/apps/api/src/organizations/center-license.service.ts new file mode 100644 index 000000000..ed7fdfff2 --- /dev/null +++ b/apps/api/src/organizations/center-license.service.ts @@ -0,0 +1,122 @@ +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { PDFDocument } from 'pdf-lib'; +import { AdministrativeAffairsPersistenceService } from '../administrative-affairs/administrative-affairs-persistence.service'; +import { AuditService } from '../audit/audit.service'; +import { DatabaseService } from '../database/database.service'; + +const types=['LICENSE','PERMIT','CERTIFICATE','REGULATORY_APPROVAL']; +export type LicenseRecordInput={type?:string;unitId?:string;referenceNumber?:string;subject?:string}; +export type LicenseAttachmentInput={mimeType?:string;base64?:string;issuedAt?:string;expiresAt?:string}; +@Injectable() +export class CenterLicenseService { + constructor(private readonly db:DatabaseService,private readonly audit:AuditService,private readonly administrative:AdministrativeAffairsPersistenceService){} + async reviewers(organizationId:string){ + const members=await this.db.organizationMember.findMany({where:{organizationId,status:'ACTIVE',role:{in:['OWNER','ADMIN']},account:{status:'ACTIVE',roleAssignments:{some:{role:'DIVE_CENTER',status:'ACTIVE'}}}},select:{account:{select:{id:true,person:{select:{firstName:true,lastName:true}},organizationMemberships:{where:{status:'ACTIVE',role:{in:['OWNER','ADMIN']},organization:{kind:{contains:'DIVE',mode:'insensitive'},status:'ACTIVE'}},orderBy:{createdAt:'asc'},take:1,select:{organizationId:true}}}}}}); + return members.filter(x=>x.account.organizationMemberships[0]?.organizationId===organizationId).map(x=>({id:x.account.id,name:[x.account.person.firstName,x.account.person.lastName].filter(Boolean).join(' ').trim()||'مراجع المركز'})); + } + private async record(organizationId:string,id:string){ + const record=await this.db.administrativeRecord.findFirst({where:{id,organizationId,type:{in:types}}}); + if(!record)throw new NotFoundException('License record not found in managed center.'); + return record; + } + private async routing(organizationId:string,id:string){ + const routing=await this.db.administrativeRouting.findFirst({where:{id,organizationId,record:{organizationId,type:{in:types}}}}); + if(!routing)throw new NotFoundException('License review not found in managed center.'); + return routing; + } + async register(accountId:string,organizationId:string,id:string){ + const record=await this.record(organizationId,id); + if(!record.licenseAssetId||!record.licenseIssuedAt||!record.licenseExpiresAt)throw new ConflictException('Attach the license and its validity dates before registration.'); + await this.download(organizationId,id); + // Lock exactly the attachment and dates validated above, even if another + // request edits the draft before the canonical registration transaction. + return this.administrative.registerRecord(id,accountId,{ + updatedAt:record.updatedAt,licenseAssetId:record.licenseAssetId, + licenseIssuedAt:record.licenseIssuedAt,licenseExpiresAt:record.licenseExpiresAt, + }); + } + async route(accountId:string,organizationId:string,id:string,toUnitId:unknown){ + await this.record(organizationId,id); + if(typeof toUnitId!=='string'||!toUnitId)throw new BadRequestException('Review unit is required.'); + const target=await this.db.orgUnit.findFirst({where:{id:toUnitId,organizationId,active:true},select:{id:true}}); + if(!target)throw new NotFoundException('Active review unit not found in managed center.'); + return this.administrative.route(id,toUnitId,accountId); + } + async assign(accountId:string,organizationId:string,id:string,assignee:unknown){ + const routing=await this.routing(organizationId,id); + if(typeof assignee!=='string'||!assignee)throw new BadRequestException('Reviewer is required.'); + if(assignee===routing.requestedByAccountId)throw new ForbiddenException('Requester cannot review own license request.'); + if(!(await this.reviewers(organizationId)).some(x=>x.id===assignee))throw new ForbiddenException('Reviewer is not eligible in this center portal.'); + return this.administrative.assign(id,assignee,accountId); + } + async decide(accountId:string,organizationId:string,id:string,decision:unknown){ + await this.routing(organizationId,id); + if(decision!=='APPROVE'&&decision!=='REJECT')throw new BadRequestException('Decision must be APPROVE or REJECT.'); + return this.administrative.decide(id,decision,accountId); + } + async create(accountId:string,organizationId:string,input:LicenseRecordInput,renewalId?:string){ + const text=(value:unknown,max:number)=>typeof value==='string'&&value.trim().length<=max?value.trim():''; + const referenceNumber=text(input?.referenceNumber,120),subject=text(input?.subject,240); + if(!referenceNumber||!subject)throw new BadRequestException('License reference and subject are required (120/240 characters maximum).'); + try{ + return await this.db.serializable(async tx=>{ + const previous=renewalId?await tx.administrativeRecord.findFirst({where:{id:renewalId,organizationId,type:{in:types}}}):null; + if(renewalId&&!previous)throw new NotFoundException('Previous license not found in managed center.'); + if(previous?.status==='DRAFT')throw new ConflictException('Finish the existing draft instead of renewing it.'); + const type=previous?.type??input?.type,unitId=previous?.unitId??input?.unitId; + if(typeof type!=='string'||!types.includes(type)||typeof unitId!=='string'||!unitId)throw new BadRequestException('A regulatory record type and active center unit are required.'); + const unit=await tx.orgUnit.findFirst({where:{id:unitId,organizationId,active:true},select:{id:true}}); + if(!unit)throw new NotFoundException('Active unit not found in managed center.'); + const record=await tx.administrativeRecord.create({data:{organizationId,unitId:unit.id,type,referenceNumber,subject,ownerAccountId:accountId,status:'DRAFT'}}); + await this.audit.record({actorId:accountId,action:previous?'CENTER_LICENSE_RENEWAL_CREATED':'CENTER_LICENSE_CREATED',resource:'AdministrativeRecord',resourceId:record.id, + metadata:{organizationId,unitId:unit.id,type,referenceNumber,renewalOfRecordId:previous?.id??null,previousReferenceNumber:previous?.referenceNumber??null}},tx); + return {id:record.id,type:record.type,referenceNumber:record.referenceNumber,subject:record.subject,status:record.status}; + }); + }catch(error){ + if(typeof error==='object'&&error!==null&&'code' in error&&error.code==='P2002')throw new ConflictException('License reference already exists in this center.'); + throw error; + } + } + private date(value:unknown){ + if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value))throw new BadRequestException('License dates must use YYYY-MM-DD.'); + const date=new Date(value+'T00:00:00.000Z'); + if(Number.isNaN(date.getTime())||date.toISOString().slice(0,10)!==value)throw new BadRequestException('Invalid license date.'); + return date; + } + async attach(accountId:string,organizationId:string,id:string,input:LicenseAttachmentInput){ + const record=await this.db.administrativeRecord.findFirst({where:{id,organizationId,type:{in:types}}}); + if(!record)throw new NotFoundException('License record not found in managed center.'); + if(record.status!=='DRAFT')throw new ConflictException('Only draft license attachments can be changed.'); + const issuedAt=this.date(input?.issuedAt),expiresAt=this.date(input?.expiresAt); + if(expiresAt<=issuedAt)throw new BadRequestException('License expiry must be after issue date.'); + const encoded=input?.base64,mimeType=input?.mimeType; + if(typeof encoded!=='string'||encoded.length>2_666_668||!encoded.length||encoded.length%4!==0||! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded))throw new BadRequestException('Invalid license file encoding or size.'); + const bytes=Buffer.from(encoded,'base64'); + if(!bytes.length||bytes.length>2_000_000)throw new BadRequestException('License file must be between 1 byte and 2 MB.'); + const png=mimeType==='image/png'&&bytes.length>=8&&bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])); + const jpeg=mimeType==='image/jpeg'&&bytes.length>=3&&bytes[0]===255&&bytes[1]===216&&bytes[2]===255; + const pdf=mimeType==='application/pdf'&&bytes.subarray(0,5).toString()==='%PDF-'; + if(!png&&!jpeg&&!pdf)throw new BadRequestException('License must be a PDF, PNG or JPEG with a matching file signature.'); + if(pdf){try{const parsed=await PDFDocument.load(bytes);if(parsed.getPageCount()<1)throw new Error('Empty PDF');}catch{throw new BadRequestException('License PDF is unreadable or encrypted.');}} + const sha256=createHash('sha256').update(bytes).digest('hex'); + return this.db.serializable(async tx=>{ + const asset=await tx.organizationDocumentAsset.upsert({where:{organizationId_sha256:{organizationId,sha256}},create:{organizationId,kind:'LICENSE_ATTACHMENT',mimeType:mimeType!,byteSize:bytes.length,sha256,content:new Uint8Array(bytes)},update:{}}); + if(asset.kind!=='LICENSE_ATTACHMENT')throw new ConflictException('File is already used as another organization asset type.'); + const claimed=await tx.administrativeRecord.updateMany({where:{id,organizationId,status:'DRAFT',updatedAt:record.updatedAt},data:{licenseAssetId:asset.id,licenseIssuedAt:issuedAt,licenseExpiresAt:expiresAt}}); + if(claimed.count!==1)throw new ConflictException('License record changed. Reload before attaching.'); + await this.audit.record({actorId:accountId,action:'CENTER_LICENSE_ATTACHED',resource:'AdministrativeRecord',resourceId:id,metadata:{organizationId,assetId:asset.id,sha256,issuedAt,expiresAt}},tx); + return {id,licenseAssetId:asset.id,licenseIssuedAt:issuedAt,licenseExpiresAt:expiresAt}; + }); + } + async download(organizationId:string,id:string){ + const record=await this.db.administrativeRecord.findFirst({where:{id,organizationId,type:{in:types}},select:{licenseAssetId:true}}); + if(!record?.licenseAssetId)throw new NotFoundException('License attachment not found.'); + const asset=await this.db.organizationDocumentAsset.findFirst({where:{id:record.licenseAssetId,organizationId,kind:'LICENSE_ATTACHMENT'}}); + if(!asset)throw new NotFoundException('License attachment not found.'); + const bytes=Buffer.from(asset.content); + if(createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw new ConflictException('License attachment integrity check failed.'); + const extension=asset.mimeType==='application/pdf'?'pdf':asset.mimeType==='image/png'?'png':'jpg'; + return {bytes,mimeType:asset.mimeType,filename:`license-${id.replace(/[^a-zA-Z0-9-]/g,'')}.${extension}`}; + } +} diff --git a/apps/api/src/organizations/dive-center-portal.controller.ts b/apps/api/src/organizations/dive-center-portal.controller.ts index 8b56a517c..19553cec8 100644 --- a/apps/api/src/organizations/dive-center-portal.controller.ts +++ b/apps/api/src/organizations/dive-center-portal.controller.ts @@ -1,6 +1,7 @@ -import { Body, Controller, Get, Param, Patch, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AccessTokenGuard } from '../auth/access-token.guard'; type AuthenticatedRequest = { auth: { accountId: string } }; +import { LicenseAttachmentInput, LicenseRecordInput } from './center-license.service'; import { DiveCenterPortalService } from './dive-center-portal.service'; @Controller('center') @@ -12,6 +13,22 @@ export class DiveCenterPortalController { @Get('me/documents') documents(@Req() request:AuthenticatedRequest){return this.portal.documents(request.auth.accountId);} + @Patch('me/licenses/:id/register') registerLicense(@Req() request:AuthenticatedRequest,@Param('id') id:string){return this.portal.registerLicense(request.auth.accountId,id);} + @Post('me/licenses/:id/reviews') routeLicense(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Body() body:{toUnitId?:string}){return this.portal.routeLicense(request.auth.accountId,id,body?.toUnitId);} + @Patch('me/license-reviews/:id/assign') assignReview(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Body() body:{assigneeAccountId?:string}){return this.portal.assignLicenseReview(request.auth.accountId,id,body?.assigneeAccountId);} + @Patch('me/license-reviews/:id/decision') decideReview(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Body() body:{decision?:string}){return this.portal.decideLicenseReview(request.auth.accountId,id,body?.decision);} + + @Post('me/licenses') createLicense(@Req() request:AuthenticatedRequest,@Body() body:LicenseRecordInput){return this.portal.createLicense(request.auth.accountId,body);} + @Post('me/licenses/:id/renew') renewLicense(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Body() body:LicenseRecordInput){return this.portal.createLicense(request.auth.accountId,body,id);} + @Patch('me/licenses/:id/attachment') attachLicense(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Body() body:LicenseAttachmentInput){return this.portal.attachLicense(request.auth.accountId,id,body);} + @Get('me/licenses/:id/attachment') async downloadLicense(@Req() request:AuthenticatedRequest,@Param('id') id:string,@Res() res:any){ + const file=await this.portal.downloadLicense(request.auth.accountId,id); + res.setHeader('Content-Type',file.mimeType); + res.setHeader('Content-Disposition',`attachment; filename="${file.filename}"`); + res.setHeader('Cache-Control','private, no-store'); + return res.send(file.bytes); + } + @Get('me/equipment/lookup/:code') equipmentLookup(@Req() request:AuthenticatedRequest,@Param('code') code:string){return this.portal.equipmentLookup(request.auth.accountId,code);} @Get('me/equipment') equipment(@Req() request:AuthenticatedRequest){return this.portal.equipment(request.auth.accountId);} @Patch('me/equipment/:resourceId/move') equipmentMove(@Req() request:AuthenticatedRequest,@Param('resourceId') resourceId:string,@Body() body:{movementType?:string;toLocation?:string|null;tripId?:string|null;notes?:string|null}){return this.portal.moveEquipment(request.auth.accountId,resourceId,body);} diff --git a/apps/api/src/organizations/dive-center-portal.service.ts b/apps/api/src/organizations/dive-center-portal.service.ts index a65ab7583..698672ad6 100644 --- a/apps/api/src/organizations/dive-center-portal.service.ts +++ b/apps/api/src/organizations/dive-center-portal.service.ts @@ -1,11 +1,26 @@ import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { AuditService } from '../audit/audit.service'; +import { EquipmentInspectionService } from '../trips/equipment-inspection.service'; +import { CenterLicenseService, LicenseAttachmentInput, LicenseRecordInput } from './center-license.service'; import { DatabaseService } from '../database/database.service'; @Injectable() export class DiveCenterPortalService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly inspections: EquipmentInspectionService, + private readonly audit: AuditService, + private readonly licenses: CenterLicenseService, + ) {} private async managedCenter(accountId: string) { + const role = await this.db.roleAssignment.findUnique({ + where: { accountId_role: { accountId, role: 'DIVE_CENTER' } }, + select: { status: true }, + }); + if (role?.status !== 'ACTIVE') { + throw new ForbiddenException('Active dive-center role required.'); + } const membership=await this.db.organizationMember.findFirst({ where:{accountId,status:'ACTIVE',role:{in:['OWNER','ADMIN']},organization:{kind:{contains:'DIVE',mode:'insensitive'},status:'ACTIVE'}}, select:{organization:{select:{id:true,displayName:true,legalName:true,registrationNumber:true,regionCode:true,status:true,documentBrandNameAr:true,documentBrandNameEn:true}}}, @@ -37,11 +52,38 @@ export class DiveCenterPortalService { async documents(accountId:string){ const center=await this.managedCenter(accountId); - const [assets,records]=await Promise.all([ + const [assets,records,units,reviewers]=await Promise.all([ this.db.organizationDocumentAsset.findMany({where:{organizationId:center.id},select:{id:true,kind:true,mimeType:true,byteSize:true,sha256:true,createdAt:true},orderBy:{createdAt:'desc'},take:200}), - this.db.administrativeRecord.findMany({where:{organizationId:center.id,type:{in:['LICENSE','PERMIT','CERTIFICATE','REGULATORY_APPROVAL']}},select:{id:true,type:true,referenceNumber:true,subject:true,status:true,createdAt:true,updatedAt:true},orderBy:{updatedAt:'desc'},take:200}), + this.db.administrativeRecord.findMany({where:{organizationId:center.id,type:{in:['LICENSE','PERMIT','CERTIFICATE','REGULATORY_APPROVAL']}},select:{id:true,type:true,referenceNumber:true,subject:true,status:true,createdAt:true,updatedAt:true,unitId:true,licenseAssetId:true,licenseIssuedAt:true,licenseExpiresAt:true,routings:{select:{id:true,decision:true,decidedAt:true,createdAt:true,requestedByAccountId:true,assignedToAccountId:true,assignedTo:{select:{person:{select:{firstName:true,lastName:true}}}},toUnit:{select:{nameAr:true,nameEn:true}}},orderBy:{createdAt:'desc'}}},orderBy:{updatedAt:'desc'},take:200}), + this.db.orgUnit.findMany({where:{organizationId:center.id,active:true},select:{id:true,nameAr:true,nameEn:true,type:true},orderBy:{nameAr:'asc'}}), + this.licenses.reviewers(center.id), ]); - return {assets,licenses:records}; + return {assets,units,licenses:records.map(({unitId,routings,...record})=>({...record, + reviewUnits:units.filter(unit=>unit.id!==unitId), + routings:routings.map(({requestedByAccountId,assignedToAccountId,assignedTo,toUnit,...routing})=>({...routing, + unitName:toUnit.nameAr||toUnit.nameEn,reviewerName:assignedTo?[assignedTo.person.firstName,assignedTo.person.lastName].filter(Boolean).join(' ').trim()||'مراجع المركز':null, + canDecide:!routing.decision&&assignedToAccountId===accountId&&requestedByAccountId!==accountId, + reviewerOptions:routing.decision?[]:reviewers.filter(x=>x.id!==requestedByAccountId), + })), + }))}; + } + + async registerLicense(accountId:string,id:string){const center=await this.managedCenter(accountId);return this.licenses.register(accountId,center.id,id);} + async routeLicense(accountId:string,id:string,toUnitId:unknown){const center=await this.managedCenter(accountId);return this.licenses.route(accountId,center.id,id,toUnitId);} + async assignLicenseReview(accountId:string,id:string,assignee:unknown){const center=await this.managedCenter(accountId);return this.licenses.assign(accountId,center.id,id,assignee);} + async decideLicenseReview(accountId:string,id:string,decision:unknown){const center=await this.managedCenter(accountId);return this.licenses.decide(accountId,center.id,id,decision);} + + async createLicense(accountId:string,input:LicenseRecordInput,renewalId?:string){ + const center=await this.managedCenter(accountId); + return this.licenses.create(accountId,center.id,input,renewalId); + } + async attachLicense(accountId:string,id:string,input:LicenseAttachmentInput){ + const center=await this.managedCenter(accountId); + return this.licenses.attach(accountId,center.id,id,input); + } + async downloadLicense(accountId:string,id:string){ + const center=await this.managedCenter(accountId); + return this.licenses.download(center.id,id); } async equipmentLookup(accountId:string,code:string){ @@ -49,7 +91,7 @@ export class DiveCenterPortalService { const rows=await this.db.$queryRaw>` SELECT b."resourceId",b."assetCode",b."barcodeValue",b."qrValue",b."serialNumber",b."sku",b."location",b."stockStatus",r."name" AS "resourceName",r."active" FROM "EquipmentBarcode" b JOIN "CalendarResource" r ON r."id"=b."resourceId" - WHERE b."organizationId"=${center.id}::uuid AND r."type"='EQUIPMENT' AND (b."assetCode"=${clean} OR b."barcodeValue"=${clean} OR b."qrValue"=${clean} OR b."serialNumber"=${clean}) LIMIT 1`; + WHERE b."organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId" AND r."type"='EQUIPMENT' AND (b."assetCode"=${clean} OR b."barcodeValue"=${clean} OR b."qrValue"=${clean} OR b."serialNumber"=${clean}) LIMIT 1`; if(!rows.length)throw new NotFoundException('Equipment code not found in managed dive center.');return rows[0]; } @@ -58,13 +100,13 @@ export class DiveCenterPortalService { return this.db.$queryRaw>` SELECT b."resourceId",b."assetCode",b."serialNumber",b."sku",b."location",b."stockStatus",r."name" AS "resourceName",r."active" FROM "EquipmentBarcode" b JOIN "CalendarResource" r ON r."id"=b."resourceId" - WHERE b."organizationId"=${center.id}::uuid AND r."type"='EQUIPMENT' + WHERE b."organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId" AND r."type"='EQUIPMENT' ORDER BY r."name",b."assetCode"`; } async moveEquipment(accountId:string,resourceId:string,input:{movementType?:string;toLocation?:string|null;tripId?:string|null;notes?:string|null}){ const center=await this.managedCenter(accountId); - const owned=await this.db.$queryRaw>`SELECT "resourceId","stockStatus","location" FROM "EquipmentBarcode" WHERE "resourceId"=${resourceId} AND "organizationId"=${center.id}::uuid LIMIT 1`; + const owned=await this.db.$queryRaw>`SELECT "resourceId","stockStatus","location" FROM "EquipmentBarcode" WHERE "resourceId"=${resourceId} AND "organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId" LIMIT 1`; if(!owned.length)throw new NotFoundException('Equipment not found in managed dive center.'); const allowed=['CHECK_IN','CHECK_OUT','TRANSFER','MAINTENANCE','QUARANTINE','RELEASE','RETIRE'];if(!input.movementType||!allowed.includes(input.movementType))throw new BadRequestException('Invalid equipment movement type.'); if(input.tripId){const trip=await this.db.trip.findFirst({where:{id:input.tripId,organizationId:center.id},select:{id:true,status:true}});if(!trip)throw new NotFoundException('Trip not found in managed dive center.');if(['CANCELLED','COMPLETED'].includes(trip.status))throw new ConflictException('Equipment cannot be assigned to a closed trip.');} @@ -73,12 +115,32 @@ export class DiveCenterPortalService { if(input.movementType==='CHECK_IN'&¤t.stockStatus!=='CHECKED_OUT')throw new ConflictException('Only checked-out equipment can be checked in.'); if(input.movementType==='RELEASE'&&!['MAINTENANCE','QUARANTINED'].includes(current.stockStatus))throw new ConflictException('Only maintained or quarantined equipment can be released.'); const next=input.movementType==='TRANSFER'?current.stockStatus:input.movementType==='CHECK_OUT'?'CHECKED_OUT':input.movementType==='MAINTENANCE'?'MAINTENANCE':input.movementType==='QUARANTINE'?'QUARANTINED':input.movementType==='RETIRE'?'RETIRED':'AVAILABLE'; - return this.db.serializable(async tx=>{const latest=await tx.$queryRaw>`SELECT "stockStatus","location" FROM "EquipmentBarcode" WHERE "resourceId"=${resourceId} AND "organizationId"=${center.id}::uuid FOR UPDATE`;if(!latest.length||latest[0].stockStatus!==current.stockStatus)throw new ConflictException('Equipment state changed. Refresh and retry.');const rows=await tx.$queryRaw`INSERT INTO "EquipmentMovement"("id","resourceId","movementType","fromLocation","toLocation","tripId","assignedAccountId","notes","actorAccountId","occurredAt") VALUES(gen_random_uuid()::text,${resourceId},${input.movementType},${latest[0].location},${input.toLocation??null},${input.tripId??null},NULL,${input.notes??null},${accountId},NOW()) RETURNING *`;await tx.$executeRaw`UPDATE "EquipmentBarcode" SET "stockStatus"=${next},"location"=COALESCE(${input.toLocation??null},"location"),"updatedAt"=NOW() WHERE "resourceId"=${resourceId} AND "organizationId"=${center.id}::uuid`;return{movement:(rows as any[])[0],stockStatus:next}}); + if(input.movementType==='CHECK_OUT'){ + const inspection=await this.inspections.evaluate([resourceId]); + if(inspection.blocked)throw new ConflictException('Equipment inspection or service policy blocks check-out.'); + } + return this.db.serializable(async tx=>{ + const latest=await tx.$queryRaw>` + SELECT "stockStatus","location" FROM "EquipmentBarcode" + WHERE "resourceId"=${resourceId} AND "organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId" FOR UPDATE`; + if(!latest.length||latest[0].stockStatus!==current.stockStatus)throw new ConflictException('Equipment state changed. Refresh and retry.'); + const rows=await tx.$queryRaw>` + INSERT INTO "EquipmentMovement"("id","resourceId","movementType","fromLocation","toLocation","tripId","assignedAccountId","notes","actorAccountId","occurredAt") + VALUES(gen_random_uuid()::text,${resourceId},${input.movementType},${latest[0].location},${input.toLocation??null},${input.tripId??null},NULL,${input.notes??null},${accountId},NOW()) RETURNING *`; + await tx.$executeRaw` + UPDATE "EquipmentBarcode" SET "stockStatus"=${next},"location"=COALESCE(${input.toLocation??null},"location"),"updatedAt"=NOW() + WHERE "resourceId"=${resourceId} AND "organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId"`; + await this.audit.record({actorId:accountId,action:'EQUIPMENT_INVENTORY_MOVED',resource:'CalendarResource',resourceId, + metadata:{organizationId:center.id,movementId:rows[0].id,movementType:input.movementType, + fromLocation:latest[0].location,toLocation:input.toLocation??null,tripId:input.tripId??null, + assignedAccountId:null,previousStatus:current.stockStatus,stockStatus:next,notes:input.notes??null}},tx); + return{movement:rows[0],stockStatus:next}; + }); } async equipmentHistory(accountId:string,resourceId:string){ const center=await this.managedCenter(accountId); - const owned=await this.db.$queryRaw>`SELECT "resourceId" FROM "EquipmentBarcode" WHERE "resourceId"=${resourceId} AND "organizationId"=${center.id}::uuid LIMIT 1`; + const owned=await this.db.$queryRaw>`SELECT "resourceId" FROM "EquipmentBarcode" WHERE "resourceId"=${resourceId} AND "organizationId"=(jsonb_populate_record(NULL::"EquipmentBarcode", jsonb_build_object('organizationId', ${center.id}::text)))."organizationId" LIMIT 1`; if(!owned.length)throw new NotFoundException('Equipment not found in managed dive center.'); return this.db.$queryRaw`SELECT "id","movementType","fromLocation","toLocation","tripId","assignedAccountId","notes","occurredAt" FROM "EquipmentMovement" WHERE "resourceId"=${resourceId} ORDER BY "occurredAt" DESC LIMIT 200`; } diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts index 8b84453e0..254f0d584 100644 --- a/apps/api/src/organizations/organizations.module.ts +++ b/apps/api/src/organizations/organizations.module.ts @@ -1,3 +1,5 @@ +import { AdministrativeAffairsModule } from '../administrative-affairs/administrative-affairs.module'; +import { CenterLicenseService } from './center-license.service'; import { Module } from '@nestjs/common'; import { AdminModule } from '../admin/admin.module'; import { AuditModule } from '../audit/audit.module'; @@ -10,8 +12,8 @@ import { DiveCenterPortalController } from './dive-center-portal.controller'; import { DiveCenterPortalService } from './dive-center-portal.service'; @Module({ - imports: [AuthModule, AdminModule, AuditModule, NotificationsModule, TripsModule], + imports: [AdministrativeAffairsModule, AuthModule, AdminModule, AuditModule, NotificationsModule, TripsModule], controllers: [OrganizationsController, DiveCenterPortalController], - providers: [OrganizationsService, DiveCenterPortalService], + providers: [OrganizationsService, DiveCenterPortalService, CenterLicenseService], }) export class OrganizationsModule {} diff --git a/apps/web/e2e/approved-portal-rebuild.spec.js b/apps/web/e2e/approved-portal-rebuild.spec.js index 6705be931..a2d7f55e6 100644 --- a/apps/web/e2e/approved-portal-rebuild.spec.js +++ b/apps/web/e2e/approved-portal-rebuild.spec.js @@ -51,15 +51,25 @@ for(const viewport of [{name:'desktop',width:1536,height:864},{name:'tablet',wid await page.locator('#navigation a[href="#hl-diver-dashboard"]').click();await expect(board).toBeVisible(); }else if(role==='instructor'){ await board.locator('[data-action-label="إدارة الدورات"]').click(); - await expect(page.locator('.hl-training')).toBeVisible();await expect(board.locator('.hl-portal-content')).toBeHidden(); + await expect(page.locator('.hl-training')).toBeVisible();await expect(page.locator('.hl-training')).toHaveAttribute('data-training-mode','professional'); await board.locator('[data-portal-home]').click();await expect(board.locator('.hl-portal-content')).toBeVisible(); + for(const [label,mode] of [['الجدول الزمني','professional-schedule'],['التقييمات','professional-skills'],['الشهادات','professional-certificates'],['الإيرادات','professional-earnings']]){ + if(viewport.name==='mobile'){await board.locator('[data-portal-menu]').click();await expect(board).toHaveClass(/hl-portal-nav-open/)} + await board.locator('.hl-portal-nav-item',{hasText:label}).click(); + await expect(page.locator('.hl-training')).toBeVisible();await expect(page.locator('.hl-training')).toHaveAttribute('data-training-mode',mode); + await board.locator('[data-portal-home]').click(); + } + await board.locator('[data-secondary-label="الملف المهني"]').click(); + await expect(page.locator('#profile-dialog')).toBeVisible();await page.locator('#profile-dialog').evaluate(node=>node.close()); } } // Loading an admin-only feature must not make it available in another workspace. await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click(); const center=page.locator('.hl-role-dashboard[data-role="center"]'); - await expect(center.locator('[data-action-label="المخزون والمعدات"]')).toBeDisabled(); - await expect(center.locator('[data-action-label="إدارة الرحلات"]')).toBeDisabled(); + await expect(center.locator('[data-action-label="المخزون والمعدات"]')).toBeEnabled(); + await expect(center.locator('[data-action-label="إدارة الرحلات"]')).toBeEnabled(); + await center.locator('[data-action-label="المخزون والمعدات"]').click(); + await expect(page.locator('#hl-center-equipment')).toBeVisible(); await expect(page.locator('.hl-inventory')).toBeHidden(); expect(errors).toEqual([]); }); diff --git a/apps/web/e2e/auth-session.spec.js b/apps/web/e2e/auth-session.spec.js index dc5f42541..fc9bce068 100644 --- a/apps/web/e2e/auth-session.spec.js +++ b/apps/web/e2e/auth-session.spec.js @@ -23,10 +23,10 @@ const seedSession = async (page,profileOptions={}) => { await page.goto('/',{waitUntil:'domcontentloaded'}); await waitForApp(page); await page.evaluate(() => { - sessionStorage.setItem('hl-access-token','e2e-access'); - sessionStorage.setItem('hl-refresh-token','e2e-refresh'); - window.HydrolandAuth.syncAuthUi(); + const attempt=window.HydrolandAuth.beginAuthAttempt(); + window.HydrolandAuth.acceptSession({accessToken:'e2e-access',refreshToken:'e2e-refresh'},attempt); }); + await page.waitForFunction(() => Boolean(window.HydrolandProfileData?.profile)); }; test('registration remains unauthenticated until email verification', async ({ page }) => { diff --git a/apps/web/e2e/center-action-reauthorization.spec.js b/apps/web/e2e/center-action-reauthorization.spec.js new file mode 100644 index 000000000..6f6af346f --- /dev/null +++ b/apps/web/e2e/center-action-reauthorization.spec.js @@ -0,0 +1,76 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const profile={id:'focused-center',email:'focused@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'مركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; +async function fixture(page,kind){ + const state={active:true,refreshes:0,reads:0,lookupCodes:[]}; + await page.route('**/api/v1/**',route=>{ + const path=new URL(route.request().url()).pathname; + if(path==='/api/v1/me'){state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})} + if(path==='/api/v1/me/diver-profile')return json(route,{profile:null,equipment:[]}); + if(path==='/api/v1/center/me/overview')return json(route,{center:{displayName:'مركز'},metrics:{}}); + if(path==='/api/v1/center/me/equipment')return json(route,[{resourceId:'eq',resourceName:'معدة',stockStatus:'AVAILABLE'}]); + if(path.startsWith('/api/v1/center/me/equipment/lookup/')){state.lookupCodes.push(decodeURIComponent(path.split('/').pop()));return json(route,{resourceId:'found',resourceName:'نتيجة البحث',stockStatus:'AVAILABLE'})} + if(path==='/api/v1/center/me/equipment/eq/history'){state.reads++;return json(route,[{movementType:'CHECK_IN'}])} + if(path==='/api/v1/center/me/trips')return json(route,[{id:'trip',title:'رحلة',status:'OPEN',_count:{bookings:1}}]); + if(path==='/api/v1/center/me/trips/trip/bookings'){state.reads++;return json(route,[{id:'book',status:'CONFIRMED',seats:1,account:{person:{firstName:'عميل'}},participants:[]}])} + return json(route,[]); + }); + await page.goto('/',{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterEquipment&&window.HydrolandCenterOperations)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'focused-access',refreshToken:'focused-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click(); + await page.locator(kind==='equipment'?'[data-portal-label="المعدات والمخزون"]':'[data-portal-label="الرحلات"]').click(); + await expect(page.locator(kind==='equipment'?'[data-equipment-history-button]':'[data-center-bookings]')).toBeVisible(); + return state; +} +test('center equipment sensitive read refreshes role and fails closed after revocation',async({page})=>{const state=await fixture(page,'equipment');const panel=page.locator('#hl-center-equipment');const before=state.refreshes;await panel.locator('[data-equipment-history-button]').click();await expect(panel.locator('[data-equipment-history]')).toContainText('CHECK_IN');expect(state.refreshes).toBeGreaterThan(before);state.active=false;await panel.locator('[data-equipment-history-button]').click();await expect(panel).toHaveCount(0)}); +test('center booking identities refresh role and fail closed after revocation',async({page})=>{const state=await fixture(page,'operations');const panel=page.locator('#hl-center-operations');const before=state.refreshes;await panel.locator('[data-center-bookings]').click();await expect(panel.locator('[data-center-booking="book"]')).toContainText('عميل');expect(state.refreshes).toBeGreaterThan(before);state.active=false;await panel.locator('[data-center-bookings]').click();await expect(panel).toHaveCount(0)}); + +for(const kind of ['equipment','operations']){ + test(`center ${kind} binds exactly one sensitive action after each workspace recreation`,async({page})=>{ + const state=await fixture(page,kind),panel=page.locator(`#hl-center-${kind}`); + for(let cycle=0;cycle<3;cycle++){ + await page.evaluate(async kind=>{document.dispatchEvent(new CustomEvent('hydroland:portal-cleared'));await (kind==='equipment'?window.HydrolandCenterEquipment:window.HydrolandCenterOperations).open()},kind); + await expect(panel).toHaveCount(1); + const reads=state.reads,refreshes=state.refreshes; + await panel.locator(kind==='equipment'?'[data-equipment-history-button]':'[data-center-bookings]').click(); + await expect(panel.locator(kind==='equipment'?'[data-equipment-history]':'[data-center-booking="book"]')).toContainText(kind==='equipment'?'CHECK_IN':'عميل'); + expect(state.reads).toBe(reads+1);expect(state.refreshes).toBeGreaterThan(refreshes); + } + }); + test(`center ${kind} discards authorization from a removed workspace`,async({page})=>{ + const state=await fixture(page,kind),panel=page.locator(`#hl-center-${kind}`); + let release,intercepted=0; + const gate=new Promise(resolve=>{release=resolve}); + await page.route(/\/api\/v1\/me$/,async route=>{intercepted++;await gate;await json(route,profile)}); + await panel.locator(kind==='equipment'?'[data-equipment-history-button]':'[data-center-bookings]').click(); + await expect.poll(()=>intercepted).toBeGreaterThan(0); + const reads=state.reads; + await page.evaluate(kind=>{ + document.dispatchEvent(new CustomEvent('hydroland:portal-cleared')); + window.HydrolandProfileData={profile:{id:'replacement-view',roles:[{role:'DIVE_CENTER',status:'ACTIVE'}]}}; + void (kind==='equipment'?window.HydrolandCenterEquipment:window.HydrolandCenterOperations).open(); + },kind); + release(); + // The central authorization layer intentionally coalesces same-session /me refreshes. + await expect.poll(()=>intercepted).toBe(1); + await page.waitForTimeout(50); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + // A removed workspace cannot issue a sensitive read; the shared authoritative refresh may safely update role state. + expect(await page.evaluate(()=>window.HydrolandProfileData?.profile?.id)).toBe('focused-center'); + expect(state.reads).toBe(reads); + await expect(panel).toBeVisible(); + await expect(panel).not.toContainText(kind==='equipment'?'CHECK_IN':'عميل'); + }); +} + +test('center equipment captures lookup input before asynchronous authorization',async({page})=>{ + const state=await fixture(page,'equipment'),panel=page.locator('#hl-center-equipment'); + const errors=[];page.on('pageerror',error=>errors.push(error.message)); + await panel.locator('[data-equipment-lookup] input').fill('ASSET / 123'); + const refreshes=state.refreshes; + await panel.locator('[data-equipment-lookup] button').click(); + await expect(panel.locator('[data-center-equipment-id="found"]')).toContainText('نتيجة البحث'); + expect(state.lookupCodes).toEqual(['ASSET / 123']);expect(state.refreshes).toBeGreaterThan(refreshes);expect(errors).toEqual([]); +}); diff --git a/apps/web/e2e/center-customers-review.spec.js b/apps/web/e2e/center-customers-review.spec.js new file mode 100644 index 000000000..f347dfb6c --- /dev/null +++ b/apps/web/e2e/center-customers-review.spec.js @@ -0,0 +1,31 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const rows=[{displayName:'عميل المركز',bookingCount:3,confirmedBookings:2,totalSeats:4,lastBookingAt:'2026-10-02T00:00:00Z'}]; +async function install(page){ + const state={active:true,status:200,body:rows,reads:0,refreshes:0};const profile={id:'center-customer-review',email:'center-customer@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>json(route,[]));await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); + await page.route(/\/api\/v1\/me$/,route=>{state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})});await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز العملاء'},metrics:{newBookings:0,tripsToday:0,activeMembers:1,totalTrips:1}})); + await page.route('**/api/v1/center/me/customers',route=>{state.reads++;return json(route,state.body,state.status)}); + await page.goto('/',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterCustomers)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'customer-access',refreshToken:'customer-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click();return state; +} +test('center customers sidebar renders booking-derived scoped customer summary',async({page})=>{ + await install(page);await page.locator('[data-portal-label="العملاء"]').click();const panel=page.locator('#hl-center-customers');await expect(panel).toBeVisible();await expect(panel.locator('[data-center-customer="0"]')).toContainText('عميل المركز');await expect(panel).toContainText('3 حجز');await expect(page.locator('#community')).not.toBeVisible(); +}); +test('center customers distinguishes denial, malformed payload and empty scope',async({page})=>{ + const state=await install(page);state.status=403;state.body={message:'Forbidden'};await page.locator('[data-portal-label="العملاء"]').click();const panel=page.locator('#hl-center-customers');await expect(panel.locator('[role="alert"]')).toContainText('لا تملك صلاحية'); + state.status=200;state.body={customers:[]};await panel.locator('[data-center-customers-retry]').click();await expect(panel.locator('[role="alert"]')).toContainText('غير مكتملة'); + state.body=[];await panel.locator('[data-center-customers-retry]').click();await expect(panel).toContainText('لا يوجد عملاء'); +}); +test('center customers escapes names and clears on logout',async({page})=>{ + const state=await install(page);state.body=[{...rows[0],displayName:''}];await page.locator('[data-portal-label="العملاء"]').click();const panel=page.locator('#hl-center-customers');await expect(panel).toContainText('window.customerXss)).toBeUndefined();await page.evaluate(()=>window.HydrolandAuth.terminateSession());await expect(panel).toHaveCount(0); +}); +test('center customers discards pending response after role revocation',async({page})=>{ + const state=await install(page);let pending;await page.route('**/api/v1/center/me/customers',route=>{pending=route});await page.locator('[data-portal-label="العملاء"]').click();await expect.poll(()=>Boolean(pending)).toBe(true);state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce()});await json(pending,rows);await expect(page.locator('#hl-center-customers')).toHaveCount(0);await expect(page.getByText('عميل المركز')).toHaveCount(0); +}); +test('center customers refreshes role before exposing booking-derived identities',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce();await window.HydrolandCenterCustomers.open()});await expect(page.locator('#hl-center-customers')).toHaveCount(0);expect(state.refreshes).toBeGreaterThanOrEqual(before+1);expect(state.reads).toBe(0);await expect(page.getByText('عميل المركز')).toHaveCount(0); +}); diff --git a/apps/web/e2e/center-documents-review.spec.js b/apps/web/e2e/center-documents-review.spec.js new file mode 100644 index 000000000..0a5ada1c9 --- /dev/null +++ b/apps/web/e2e/center-documents-review.spec.js @@ -0,0 +1,144 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const payload={assets:[{id:'asset-a',kind:'LICENSE_SCAN',mimeType:'application/pdf',byteSize:2048,createdAt:'2026-10-02T00:00:00Z'}],licenses:[{id:'license-a',type:'LICENSE',referenceNumber:'LIC-001',subject:'ترخيص المركز',status:'ACTIVE',updatedAt:'2026-10-02T00:00:00Z'}]}; +async function install(page){ + const state={active:true,status:200,body:payload,reads:0,refreshes:0}; + const profile={id:'center-doc-review',email:'center-doc@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>json(route,[])); + await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); + await page.route(/\/api\/v1\/me$/,route=>{state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})}); + await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز المستندات'},metrics:{newBookings:0,tripsToday:0,activeMembers:1,totalTrips:0}})); + await page.route('**/api/v1/center/me/documents',route=>{state.reads++;return json(route,state.body,state.status)}); + await page.goto('/',{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterDocuments)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'doc-access',refreshToken:'doc-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click(); + await expect(page.locator('.hl-role-dashboard[data-role="center"]')).toBeVisible();return state; +} +test('center documents sidebar renders scoped assets and licenses, not generic documents',async({page})=>{ + await install(page);const dash=page.locator('.hl-role-dashboard[data-role="center"]'); + await dash.locator('[data-portal-label="المستندات والتراخيص"]').click(); + const panel=page.locator('#hl-center-documents');await expect(panel).toBeVisible(); + await expect(panel.locator('[data-center-license="license-a"]')).toContainText('LIC-001'); + await expect(panel.locator('[data-center-asset="asset-a"]')).toContainText('LICENSE_SCAN'); + await expect(page.locator('#hl-documents')).not.toBeVisible(); +}); +test('center documents distinguishes denied, malformed and valid empty responses',async({page})=>{ + const state=await install(page);state.status=403;state.body={message:'Forbidden'}; + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const panel=page.locator('#hl-center-documents'); + await expect(panel.locator('[role="alert"]')).toContainText('لا تملك صلاحية'); + state.status=200;state.body={assets:[],licenses:null};await panel.locator('[data-center-documents-retry]').click(); + await expect(panel.locator('[role="alert"]')).toContainText('غير مكتملة'); + state.body={assets:[],licenses:[]};await panel.locator('[data-center-documents-retry]').click(); + await expect(panel).toContainText('لا توجد تراخيص');await expect(panel).toContainText('لا توجد ملفات'); +}); +test('center documents escapes service text and clears on logout',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[{id:'x',subject:'',type:'LICENSE',status:'ACTIVE'}]}; + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const panel=page.locator('#hl-center-documents'); + await expect(panel).toContainText('window.docXss)).toBeUndefined(); + await page.evaluate(()=>window.HydrolandAuth.terminateSession());await expect(panel).toHaveCount(0); +}); +test('center documents discards pending data after role revocation',async({page})=>{ + const state=await install(page);let pending;await page.route('**/api/v1/center/me/documents',route=>{pending=route}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();await expect.poll(()=>Boolean(pending)).toBe(true); + state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce()}); + await json(pending,payload);await expect(page.locator('#hl-center-documents')).toHaveCount(0);await expect(page.locator('[data-center-license="license-a"]')).toHaveCount(0); +}); +test('center documents refreshes role before exposing scoped licenses and assets',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(()=>window.HydrolandCenterDocuments.open());await expect(page.locator('#hl-center-documents')).toHaveCount(0);expect(state.refreshes).toBeGreaterThan(before);expect(state.reads).toBe(0);await expect(page.getByText('LIC-001')).toHaveCount(0); +}); + +test('draft license upload reauthorizes, sends file and dates, then refreshes linked record',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[{id:'draft-license',subject:'رخصة مسودة',type:'LICENSE',status:'DRAFT',routings:[]}]}; + let uploaded; + await page.route('**/api/v1/center/me/licenses/draft-license/attachment',route=>{ + uploaded=route.request().postDataJSON();state.body.licenses[0]={...state.body.licenses[0],licenseAssetId:'linked',licenseIssuedAt:'2025-01-01',licenseExpiresAt:'2030-01-01'}; + return json(route,{id:'draft-license',licenseAssetId:'linked'}); + }); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click(); + const form=page.locator('[data-license-attachment="draft-license"]'); + await form.locator('[name="issuedAt"]').fill('2025-01-01');await form.locator('[name="expiresAt"]').fill('2030-01-01'); + await form.locator('[type="file"]').setInputFiles({name:'license.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-fixture')}); + const before=state.refreshes;await form.locator('[type="submit"]').click(); + await expect(page.locator('[data-license-download="draft-license"]')).toBeVisible(); + expect(uploaded).toEqual({mimeType:'application/pdf',base64:Buffer.from('%PDF-fixture').toString('base64'),issuedAt:'2025-01-01',expiresAt:'2030-01-01'});expect(state.refreshes).toBeGreaterThan(before); +}); +test('registered license shows expired validity and internal review separately and locks upload',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[{id:'reviewed',subject:'رخصة',type:'LICENSE',status:'REGISTERED',licenseAssetId:'asset',licenseIssuedAt:'2020-01-01',licenseExpiresAt:'2021-01-01',routings:[{decision:'APPROVE'}]}]}; + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const card=page.locator('[data-center-license="reviewed"]'); + await expect(card).toContainText('منتهية الصلاحية');await expect(card).toContainText('مقبولة بالمراجعة الداخلية');await expect(card.locator('[data-license-attachment]')).toHaveCount(0); + await expect(page.locator('#hl-center-documents')).toContainText('لا تمثل تحققًا من الجهة المصدرة'); +}); +test('license download is revoked before requesting private bytes',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[{id:'private',status:'REGISTERED',licenseAssetId:'asset'}]};let downloads=0; + await page.route('**/api/v1/center/me/licenses/private/attachment',route=>{downloads++;return json(route,{})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click(); + await expect(page.locator('[data-license-download="private"]')).toBeVisible();state.active=false; + await page.locator('[data-license-download="private"]').click();await expect(page.locator('#hl-center-documents')).toHaveCount(0);expect(downloads).toBe(0); +}); +test('license upload cannot write after role revocation',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[{id:'private',status:'DRAFT'}]};let writes=0; + await page.route('**/api/v1/center/me/licenses/private/attachment',route=>{writes++;return json(route,{})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const form=page.locator('[data-license-attachment="private"]'); + await form.locator('[name="issuedAt"]').fill('2025-01-01');await form.locator('[name="expiresAt"]').fill('2030-01-01'); + await form.locator('[type="file"]').setInputFiles({name:'license.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-fixture')});state.active=false; + await form.locator('[type="submit"]').click();await expect(page.locator('#hl-center-documents')).toHaveCount(0);expect(writes).toBe(0); +}); + +test('center creates a scoped license draft and can immediately attach its file',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[],units:[{id:'unit-a',nameAr:'وحدة المركز'}]};let created; + await page.route('**/api/v1/center/me/licenses',route=>{created=route.request().postDataJSON();state.body.licenses.push({id:'new-license',status:'DRAFT',...created});return json(route,{id:'new-license',status:'DRAFT'},201)}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();await page.getByText('إنشاء سجل رخصة جديد',{exact:true}).click(); + const form=page.locator('[data-license-create]');await form.locator('[name="referenceNumber"]').fill('LIC-NEW-1');await form.locator('[name="subject"]').fill('رخصة المركز الجديدة'); + const before=state.refreshes;await form.locator('[type="submit"]').click(); + await expect(page.locator('[data-license-attachment="new-license"]')).toBeVisible(); + expect(created).toEqual({referenceNumber:'LIC-NEW-1',subject:'رخصة المركز الجديدة',type:'LICENSE',unitId:'unit-a'});expect(state.refreshes).toBeGreaterThan(before); +}); +test('renewal creates a separate draft without replacing the old license',async({page})=>{ + const state=await install(page);state.body={assets:[],units:[],licenses:[{id:'old-license',subject:'الرخصة الأصلية',referenceNumber:'OLD-1',type:'LICENSE',status:'REGISTERED',licenseAssetId:'old-asset'}]};let renewed; + await page.route('**/api/v1/center/me/licenses/old-license/renew',route=>{renewed=route.request().postDataJSON();state.body.licenses.push({id:'renewed-license',status:'DRAFT',type:'LICENSE',...renewed});return json(route,{id:'renewed-license',status:'DRAFT'},201)}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();await page.locator('[data-center-license="old-license"] summary').click(); + const form=page.locator('[data-license-renew="old-license"]');await form.locator('[name="referenceNumber"]').fill('NEW-2');await form.locator('[type="submit"]').click(); + await expect(page.locator('[data-license-attachment="renewed-license"]')).toBeVisible();await expect(page.locator('[data-license-download="old-license"]')).toBeVisible(); + expect(renewed).toEqual({referenceNumber:'NEW-2',subject:'الرخصة الأصلية'}); +}); +test('duplicate reference error preserves draft form for correction',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[],units:[{id:'unit-a',nameAr:'المركز'}]};await page.route('**/api/v1/center/me/licenses',route=>json(route,{message:'Duplicate reference'},409)); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();await page.getByText('إنشاء سجل رخصة جديد',{exact:true}).click(); + const form=page.locator('[data-license-create]');await form.locator('[name="referenceNumber"]').fill('DUPLICATE');await form.locator('[name="subject"]').fill('رخصة');await form.locator('[type="submit"]').click(); + await expect(form.locator('[data-license-feedback]')).toContainText('الرقم المرجعي مستخدم');await expect(form.locator('[name="referenceNumber"]')).toHaveValue('DUPLICATE');await expect(form.locator('[type="submit"]')).toBeEnabled(); +}); +test('license creation rechecks revoked center role before sending a write',async({page})=>{ + const state=await install(page);state.body={assets:[],licenses:[],units:[{id:'unit-a',nameAr:'المركز'}]};let writes=0;await page.route('**/api/v1/center/me/licenses',route=>{writes++;return json(route,{})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();await page.getByText('إنشاء سجل رخصة جديد',{exact:true}).click();const form=page.locator('[data-license-create]'); + await form.locator('[name="referenceNumber"]').fill('REF');await form.locator('[name="subject"]').fill('رخصة');state.active=false;await form.locator('[type="submit"]').click(); + await expect(page.locator('#hl-center-documents')).toHaveCount(0);expect(writes).toBe(0); +}); + +test('license registration, routing and reviewer assignment use scoped actions in order',async({page})=>{ + const state=await install(page),calls=[];const row={id:'review-license',subject:'رخصة للمراجعة',status:'DRAFT',licenseAssetId:'asset',reviewUnits:[{id:'review-unit',nameAr:'وحدة المراجعة'}],routings:[]};state.body={assets:[],units:[],licenses:[row]}; + await page.route('**/api/v1/center/me/licenses/review-license/register',route=>{calls.push('register');row.status='REGISTERED';return json(route,{status:'REGISTERED'})}); + await page.route('**/api/v1/center/me/licenses/review-license/reviews',route=>{calls.push(route.request().postDataJSON());row.routings=[{id:'review-one',reviewerOptions:[{id:'reviewer',name:'المراجع المستقل'}],canDecide:false,unitName:'وحدة المراجعة'}];return json(route,{id:'review-one'},201)}); + await page.route('**/api/v1/center/me/license-reviews/review-one/assign',route=>{calls.push(route.request().postDataJSON());row.routings[0].reviewerName='المراجع المستقل';return json(route,{id:'review-one'})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click(); + await page.locator('[data-license-review-action="register"] button').click();await expect(page.locator('[data-license-attachment]')).toHaveCount(0); + await page.locator('[data-license-review-action="route"] button').click();await page.locator('[data-license-review-action="assign"] button').click(); + await expect(page.locator('[data-license-review="review-one"]')).toContainText('المراجع: المراجع المستقل');await expect(page.locator('[data-license-review-action="decide"]')).toHaveCount(0); + expect(calls).toEqual(['register',{toUnitId:'review-unit'},{assigneeAccountId:'reviewer'}]); +}); +for(const decision of ['APPROVE','REJECT'])test(`assigned license reviewer records ${decision} and loses final-decision controls`,async({page})=>{ + const state=await install(page);const review={id:'assigned',canDecide:true,reviewerName:'مراجع',reviewerOptions:[],unitName:'المراجعة'};state.body={assets:[],licenses:[{id:'reviewed',status:'REGISTERED',routings:[review]}]};let posted; + await page.route('**/api/v1/center/me/license-reviews/assigned/decision',route=>{posted=route.request().postDataJSON();review.decision=posted.decision;return json(route,{decision})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const form=page.locator('[data-license-review-action="decide"]');await form.locator('select').selectOption(decision);await form.locator('button').click(); + await expect(page.locator('[data-license-review="assigned"]')).toContainText(decision==='APPROVE'?'قبول داخلي':'رفض داخلي');await expect(page.locator('[data-license-review-action="decide"]')).toHaveCount(0);expect(posted).toEqual({decision}); +}); +for(const action of ['register','route','assign','decide'])test(`license ${action} refreshes revoked access before writing`,async({page})=>{ + const state=await install(page);const row={id:'guarded',status:action==='register'?'DRAFT':'REGISTERED',licenseAssetId:'asset',reviewUnits:[{id:'review-unit',nameAr:'المراجعة'}],routings:action==='assign'||action==='decide'?[{id:'pending',canDecide:true,reviewerOptions:[{id:'reviewer',name:'مراجع'}]}]:[]};state.body={assets:[],licenses:[row]};let writes=0; + await page.route(/\/api\/v1\/center\/me\/(?:licenses|license-reviews)\//,route=>{writes++;return json(route,{})}); + await page.locator('[data-portal-label="المستندات والتراخيص"]').click();const form=page.locator(`[data-license-review-action="${action}"]`);await expect(form).toBeVisible();if(action==='decide')await form.locator('select').selectOption('APPROVE');const before=state.refreshes;state.active=false; + // Positive flow covers clicks. Native submit avoids scroll/focus refresh removing the form before this authorization test can submit it. + await form.evaluate(node=>node.requestSubmit()); + await expect(page.locator('#hl-center-documents')).toHaveCount(0);expect(writes).toBe(0);expect(state.refreshes).toBeGreaterThan(before); +}); diff --git a/apps/web/e2e/center-equipment-review.spec.js b/apps/web/e2e/center-equipment-review.spec.js new file mode 100644 index 000000000..4c652c67f --- /dev/null +++ b/apps/web/e2e/center-equipment-review.spec.js @@ -0,0 +1,36 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const item={resourceId:'eq-a',assetCode:'A-001',serialNumber:'S-001',sku:'SKU-1',location:'المستودع',stockStatus:'AVAILABLE',resourceName:'منظم غوص',active:true}; +async function install(page){ + const state={active:true,moves:0,refreshes:0,reads:0}; + const profile={id:'center-eq-review',email:'center-eq@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>{const path=new URL(route.request().url()).pathname;if(path==='/api/v1/me'){state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})}return json(route,[])});await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); +await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز المعدات'},metrics:{newBookings:0,tripsToday:0,activeMembers:1,totalTrips:0}})); + await page.route(/\/api\/v1\/center\/me\/equipment$/,route=>json(route,[item])); + await page.route('**/api/v1/center/me/equipment/lookup/*',route=>json(route,item)); + await page.route('**/api/v1/center/me/equipment/eq-a/history',route=>json(route,[{movementType:'CHECK_IN',toLocation:'المستودع'}])); + await page.route('**/api/v1/center/me/equipment/eq-a/move',route=>{state.moves++;return json(route,{stockStatus:'CHECKED_OUT'})}); + await page.goto('/',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterEquipment)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'eq-access',refreshToken:'eq-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click();return state; +} +test('center equipment sidebar uses scoped inventory, lookup and movement history',async({page})=>{ + await install(page);const dash=page.locator('.hl-role-dashboard[data-role="center"]');await dash.locator('[data-portal-label="المعدات والمخزون"]').click(); + const panel=page.locator('#hl-center-equipment');await expect(panel).toBeVisible();await expect(panel.locator('[data-center-equipment-id="eq-a"]')).toContainText('منظم غوص'); + await panel.locator('[data-equipment-history-button]').click();await expect(panel.locator('[data-equipment-history]')).toContainText('CHECK_IN'); + await panel.locator('[data-equipment-lookup] input').fill('A-001');await panel.locator('[data-equipment-lookup]').evaluate(form=>form.requestSubmit());await expect(panel.locator('[data-center-equipment-id="eq-a"]')).toBeVisible(); +}); +test('center equipment reauthorizes before state mutation',async({page})=>{ + const state=await install(page);await page.locator('[data-portal-label="المعدات والمخزون"]').click();await expect(page.locator('#hl-center-equipment [data-move="CHECK_OUT"]')).toBeVisible();state.active=false; + await page.locator('#hl-center-equipment [data-move="CHECK_OUT"]').click();await expect.poll(()=>state.moves).toBe(0);await expect(page.locator('#hl-center-equipment')).toHaveCount(0); +}); +test('center equipment escapes API text and clears on logout',async({page})=>{ + await install(page);await page.unroute(/\/api\/v1\/center\/me\/equipment$/);await page.route(/\/api\/v1\/center\/me\/equipment$/,route=>json(route,[{...item,resourceName:''}])); + await page.locator('[data-portal-label="المعدات والمخزون"]').click();const panel=page.locator('#hl-center-equipment');await expect(panel).toContainText('window.eqXss)).toBeUndefined(); + await page.evaluate(()=>window.HydrolandAuth.terminateSession());await expect(panel).toHaveCount(0); +}); +test('center equipment refreshes role before exposing inventory',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce();await window.HydrolandCenterEquipment.open()});await expect(page.locator('#hl-center-equipment')).toHaveCount(0);expect(state.refreshes).toBeGreaterThanOrEqual(before+1);expect(state.reads).toBe(0);await expect(page.getByText('منظم غوص')).toHaveCount(0); +}); diff --git a/apps/web/e2e/center-operations-review.spec.js b/apps/web/e2e/center-operations-review.spec.js new file mode 100644 index 000000000..694e538e9 --- /dev/null +++ b/apps/web/e2e/center-operations-review.spec.js @@ -0,0 +1,29 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const trips=[{id:'trip-a',title:'رحلة المركز',type:'BOAT',startsAt:'2030-01-01T07:00:00Z',capacity:6,status:'OPEN',_count:{bookings:1}}]; +const bookings=[{id:'booking-a',status:'CONFIRMED',seats:2,account:{person:{firstName:'عميل',lastName:'خاص'}},participants:[{id:'p1',fullName:'مشارك خاص'}]}]; +async function install(page){ + const state={active:true,bookingReads:0,tripReads:0,refreshes:0};const profile={id:'center-ops-review',email:'center-ops@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>{const path=new URL(route.request().url()).pathname;if(path==='/api/v1/me'){state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})}return json(route,[])});await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); +await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز العمليات'},metrics:{newBookings:1,tripsToday:1,activeMembers:1,totalTrips:1}})); + await page.route(/\/api\/v1\/center\/me\/trips$/,route=>json(route,trips));await page.route('**/api/v1/center/me/trips/trip-a/bookings',route=>{state.bookingReads++;return json(route,bookings)}); + await page.goto('/',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterOperations)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'ops-access',refreshToken:'ops-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click();return state; +} +test('center operations sidebar exposes only center trip and booking details',async({page})=>{ + await install(page);await page.locator('[data-portal-label="الرحلات"]').click();const panel=page.locator('#hl-center-operations');await expect(panel).toBeVisible();await expect(panel.locator('[data-center-trip="trip-a"]')).toContainText('رحلة المركز'); + await panel.locator('[data-center-bookings]').click();await expect(panel.locator('[data-center-booking="booking-a"]')).toContainText('عميل خاص');await expect(panel).toContainText('المشاركون: 1'); +}); +test('center operations reauthorizes before exposing booking identities',async({page})=>{ + const state=await install(page);await page.locator('[data-portal-label="الرحلات"]').click();await expect(page.locator('#hl-center-operations [data-center-bookings]')).toBeVisible();state.active=false;await page.locator('#hl-center-operations [data-center-bookings]').click();await expect.poll(()=>state.bookingReads).toBe(0);await expect(page.locator('#hl-center-operations')).toHaveCount(0);await expect(page.getByText('عميل خاص')).toHaveCount(0); +}); +test('center operations escapes trip and customer names and clears on logout',async({page})=>{ + await install(page);await page.unroute(/\/api\/v1\/center\/me\/trips$/);await page.route(/\/api\/v1\/center\/me\/trips$/,route=>json(route,[{...trips[0],title:''}])); + await page.locator('[data-portal-label="الرحلات"]').click();const panel=page.locator('#hl-center-operations');await expect(panel).toContainText('window.tripXss)).toBeUndefined();await page.evaluate(()=>window.HydrolandAuth.terminateSession());await expect(panel).toHaveCount(0); +}); +test('center operations refreshes role before exposing center trips',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce();await window.HydrolandCenterOperations.open()});await expect(page.locator('#hl-center-operations')).toHaveCount(0);expect(state.refreshes).toBeGreaterThanOrEqual(before+1);expect(state.tripReads).toBe(0);expect(state.bookingReads).toBe(0);await expect(page.getByText('رحلة المركز')).toHaveCount(0); +}); diff --git a/apps/web/e2e/center-portal.spec.js b/apps/web/e2e/center-portal.spec.js index 6f0c14bae..0196f0152 100644 --- a/apps/web/e2e/center-portal.spec.js +++ b/apps/web/e2e/center-portal.spec.js @@ -14,6 +14,7 @@ const installCenterFixture=async page=>{ await page.route(/\/api\/v1\/credentials$/,route=>authorized(route.request())?json(route,[]):json(route,{message:'Unauthorized'},401)); await page.route(/\/api\/v1\/organizations\/mine$/,route=>authorized(route.request())?json(route,[center]):json(route,{message:'Unauthorized'},401)); await page.route(/\/api\/v1\/marine-operations\/assets\/mine$/,route=>authorized(route.request())?json(route,[]):json(route,{message:'Unauthorized'},401)); + await page.route(/\/api\/v1\/center\/me\/documents$/,route=>authorized(route.request())?json(route,{assets:[],licenses:[{id:'center-license-001',type:'LICENSE',referenceNumber:'LIC-E2E-001',subject:'ترخيص مركز الاختبار',status:'ACTIVE',updatedAt:'2026-10-02T00:00:00Z'}]}):json(route,{message:'Unauthorized'},401)); await page.route(new RegExp(`/api/v1/documents/organizations/${center.organization.id}/templates$`),route=>authorized(route.request())?json(route,[template]):json(route,{message:'Unauthorized'},401)); await page.route(new RegExp(`/api/v1/documents/organizations/${center.organization.id}/list$`),route=>authorized(route.request())?json(route,state.documents):json(route,{message:'Unauthorized'},401)); await page.route(/\/api\/v1\/documents$/,async route=>{ @@ -43,18 +44,12 @@ test('center portal opens center documents and saves a test report under its act await expect(dashboard.locator('.hl-portal-nav-item[data-portal-label="المستندات والتراخيص"]')).toBeEnabled(); await dashboard.locator('.hl-portal-nav-item[data-portal-label="المستندات والتراخيص"]').click(); - const panel=page.locator('#hl-documents'); + const panel=page.locator('#hl-center-documents'); await expect(panel).toBeVisible(); - await expect(panel.locator('[data-doc-org]')).toContainText('مركز HYDROLAND للاختبار'); - await expect(panel.locator('[data-doc-template]')).toContainText('تقرير تشغيل مركز الغوص'); - await panel.locator('[name="summary"]').fill('فحص تشغيل مركز الغوص التجريبي'); - await panel.locator('[data-doc-save]').click(); - await expect.poll(()=>state.createdPayload?.payload?.summary).toBe('فحص تشغيل مركز الغوص التجريبي'); - await expect(panel.locator('[data-document-id="center-document-001"]')).toContainText('HYD-CENTER-E2E-001'); - await expect(panel.locator('[data-doc-note]')).toContainText('تم حفظ المستند كمسودة.'); + await expect(panel.locator('[data-center-license="center-license-001"]')).toContainText('LIC-E2E-001'); + await expect(page.locator('#hl-documents')).toBeHidden(); await dashboard.locator('[data-portal-home]').click(); await expect(dashboard.locator('.hl-portal-content')).toBeVisible(); - await page.evaluate(()=>{document.getElementById('hl-documents').hidden=true}); await dashboard.locator('.hl-portal-nav-item[data-portal-label="المستندات والتراخيص"]').click(); await expect(panel).toBeVisible(); }); diff --git a/apps/web/e2e/center-safety-review.spec.js b/apps/web/e2e/center-safety-review.spec.js new file mode 100644 index 000000000..984d9ef4f --- /dev/null +++ b/apps/web/e2e/center-safety-review.spec.js @@ -0,0 +1,99 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; + +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const sample={checklists:[{id:'center-check',decision:'GO',notes:'فحص المركز',updatedAt:'2026-10-02T00:00:00Z',trip:{title:'رحلة المركز الخاصة'}}],incidents:[{id:'center-incident',title:'بلاغ المركز',severity:'LOW',status:'OPEN',createdAt:'2026-10-02T00:00:00Z',trip:{title:'رحلة المركز الخاصة'}}]}; +const install=async page=>{ + const state={active:true,status:200,body:sample,reads:0,refreshes:0}; + const profile={id:'center-safety-review',email:'center-review@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>json(route,[])); + await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); + await page.route(/\/api\/v1\/me$/,route=>{state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})}); + await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز نطاق الاختبار'},metrics:{newBookings:1,tripsToday:2,activeMembers:3,totalTrips:4}})); + await page.route('**/api/v1/center/me/safety',route=>{state.reads++;return json(route,state.body,state.status)}); + await page.goto('/',{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterSafety)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'center-review-access',refreshToken:'center-review-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click(); + await expect(page.locator('.hl-role-dashboard[data-role="center"]')).toBeVisible(); + return state; +}; +const quick=page=>page.locator('.hl-command [data-action-label="تقارير السلامة"]'); + +for(const viewport of [{name:'mobile',width:390,height:844},{name:'desktop',width:1440,height:1000}]){ + test(`center safety quick action, secondary action and sidebar use the scoped records on ${viewport.name}`,async({page})=>{ + await page.setViewportSize(viewport);await install(page); + const dashboard=page.locator('.hl-role-dashboard[data-role="center"]'); + await expect(dashboard.locator('[data-center-name]')).toHaveText('مركز نطاق الاختبار'); + await expect(dashboard.locator('.hl-role-tile').nth(2)).toContainText('أعضاء نشطون'); + await expect(dashboard.locator('.hl-role-tile').nth(3)).toContainText('إجمالي الرحلات'); + await page.evaluate(()=>window.HydrolandProfile.load()); + await expect(dashboard.locator('[data-center-name]')).toHaveText('مركز نطاق الاختبار'); + await expect(quick(page)).toBeEnabled();await quick(page).click(); + const panel=page.locator('#hl-center-safety'); + await expect(panel).toBeVisible(); + await expect(panel.locator('[data-center-checklist="center-check"]')).toContainText('رحلة المركز الخاصة'); + await expect(panel.locator('[data-center-incident="center-incident"]')).toContainText('بلاغ المركز'); + await expect(panel).not.toContainText('Invalid Date'); + await dashboard.locator('[data-portal-home]').click(); + await dashboard.locator('.hl-safety-board [data-action-label="تقارير السلامة"]').click(); + await expect(panel).toBeVisible(); + await dashboard.locator('[data-portal-home]').click(); + if(viewport.name==='mobile')await dashboard.locator('[data-portal-menu]').click(); + await dashboard.locator('[data-portal-label="السلامة"]').click(); + await expect(panel).toBeVisible(); + await expect(panel.locator('[data-center-checklist]')).toHaveCount(1); + }); +} + +test('center safety exposes denied and malformed responses as errors, not empty success',async({page})=>{ + const state=await install(page);state.status=403;state.body={message:'Forbidden'}; + await quick(page).click();const panel=page.locator('#hl-center-safety'); + await expect(panel.locator('[role="alert"]')).toContainText('لا تملك صلاحية'); + await expect(panel.locator('[data-center-incident]')).toHaveCount(0); + state.status=200;state.body={checklists:[],incidents:null}; + await panel.locator('[data-center-safety-retry]').click(); + await expect(panel.locator('[role="alert"]')).toContainText('غير مكتملة'); + state.body={checklists:[],incidents:[]}; + await panel.locator('[data-center-safety-retry]').click(); + await expect(panel).toContainText('لا توجد قوائم فحص'); + await expect(panel.locator('[role="alert"]')).toHaveCount(0); +}); + +test('center safety escapes service data rather than interpreting markup',async({page})=>{ + const state=await install(page);state.body={checklists:[],incidents:[{id:'markup',title:'',severity:'LOW',status:'OPEN',createdAt:null}]}; + await quick(page).click();const panel=page.locator('#hl-center-safety'); + await expect(panel).toContainText('window.reviewXss)).toBeUndefined(); +}); + +test('center safety discards older overlapping results',async({page})=>{ + await install(page);const pending=[]; + await page.route('**/api/v1/center/me/safety',route=>{pending.push(route)}); + await quick(page).click();await expect.poll(()=>pending.length).toBe(1); + await page.evaluate(()=>{void window.HydrolandCenterSafety.open()}); + await expect.poll(()=>pending.length).toBe(2); + await json(pending[1],{checklists:[],incidents:[{id:'new',title:'السجل الأحدث',severity:'LOW',status:'OPEN'}]}); + await expect(page.locator('#hl-center-safety')).toContainText('السجل الأحدث'); + await json(pending[0],sample); + await expect(page.locator('#hl-center-safety [data-center-incident="new"]')).toHaveCount(1); + await expect(page.locator('#hl-center-safety [data-center-incident="center-incident"]')).toHaveCount(0); +}); + +for(const reason of ['logout','revocation']){ + test(`center safety clears private data and discards pending results after ${reason}`,async({page})=>{ + const state=await install(page);let pending; + await page.route('**/api/v1/center/me/safety',route=>{pending=route}); + await quick(page).click();await expect.poll(()=>Boolean(pending)).toBe(true); + if(reason==='logout')await page.evaluate(()=>window.HydrolandAuth.terminateSession()); + else{state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce()})} + await json(pending,sample); + await expect(page.locator('#hl-center-safety')).toHaveCount(0); + await expect(page.locator('[data-center-incident="center-incident"]')).toHaveCount(0); + }); +} + +test('center safety refreshes role before exposing scoped safety records',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(()=>window.HydrolandCenterSafety.open());await expect(page.locator('#hl-center-safety')).toHaveCount(0);expect(state.refreshes).toBeGreaterThan(before);expect(state.reads).toBe(0);await expect(page.getByText('بلاغ المركز')).toHaveCount(0); +}); diff --git a/apps/web/e2e/center-team-review.spec.js b/apps/web/e2e/center-team-review.spec.js new file mode 100644 index 000000000..0e89dcb56 --- /dev/null +++ b/apps/web/e2e/center-team-review.spec.js @@ -0,0 +1,29 @@ +import {test,expect} from '@playwright/test'; +import {openWorkspaceSwitcher} from './portal-test-helpers.js'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const team=[{membershipId:'m1',role:'INSTRUCTOR',status:'ACTIVE',person:{displayName:'عضو المركز',headline:'Dive Instructor'},professional:{instructorStatus:'ACTIVE',verifiedCredentials:2}}]; +const pros=[{accountId:'p1',displayName:'محترف المركز',headline:'مدرب غوص',verifiedCredentials:3,assignedTrainingCount:4}]; +async function install(page){ + const state={active:true,team,pros,reads:0,refreshes:0};const profile={id:'center-team-review',email:'center-team@example.invalid',status:'ACTIVE',person:{firstName:'مدير',lastName:'المركز'},roleAssignments:[{role:'DIVE_CENTER',status:'ACTIVE'}]}; + await page.route('**/api/v1/**',route=>{const path=new URL(route.request().url()).pathname;if(path.endsWith('/me'))return route.fallback();return json(route,[])});await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); + await page.route(/\/api\/v1\/me$/,route=>{state.refreshes++;return json(route,{...profile,roleAssignments:state.active?profile.roleAssignments:[]})});await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{profile:null,equipment:[]})); + await page.route('**/api/v1/center/me/overview',route=>json(route,{center:{displayName:'مركز الفريق'},metrics:{newBookings:0,tripsToday:0,activeMembers:2,totalTrips:0}})); + await page.route('**/api/v1/center/me/team',route=>{state.reads++;return json(route,state.team)});await page.route('**/api/v1/center/me/professionals',route=>{state.reads++;return json(route,state.pros)}); + await page.goto('/',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile&&window.HydrolandCenterTeam)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'team-access',refreshToken:'team-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await openWorkspaceSwitcher(page);await page.locator('#role-dialog [data-role="center"]').click();return state; +} +test('center professionals can switch to center team membership',async({page})=>{ + await install(page);await page.locator('[data-portal-label="محترفي الغوص"]').click();const panel=page.locator('#hl-center-team');await expect(panel).toBeVisible();await expect(panel).toContainText('محترف المركز');await expect(panel).toContainText('التكليفات التدريبية: 4'); + await panel.locator('[data-center-team-mode="team"]').click();await expect(panel).toContainText('عضو المركز');await expect(panel).toContainText('Dive Instructor'); +}); +test('center team rejects malformed payload and clears on logout',async({page})=>{ + const state=await install(page);state.pros={members:[]};await page.locator('[data-portal-label="محترفي الغوص"]').click();const panel=page.locator('#hl-center-team');await expect(panel.locator('[role="alert"]')).toContainText('غير مكتملة'); + state.pros=pros;await panel.locator('[data-center-team-retry]').click();await expect(panel).toContainText('محترف المركز');await page.evaluate(()=>window.HydrolandAuth.terminateSession());await expect(panel).toHaveCount(0); +}); +test('center team discards response after revocation',async({page})=>{ + const state=await install(page);await page.locator('[data-portal-label="محترفي الغوص"]').click();const panel=page.locator('#hl-center-team');let pending;await page.route('**/api/v1/center/me/team',route=>{pending=route});await panel.locator('[data-center-team-mode="team"]').click();await expect.poll(()=>Boolean(pending)).toBe(true);state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce()});await json(pending,team);await expect(page.locator('#hl-center-team')).toHaveCount(0);await expect(page.getByText('عضو المركز')).toHaveCount(0); +}); +test('center team refreshes role before exposing member identities',async({page})=>{ + const state=await install(page),before=state.refreshes;state.active=false;await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce();await window.HydrolandCenterTeam.open('professionals')});await expect(page.locator('#hl-center-team')).toHaveCount(0);expect(state.refreshes).toBeGreaterThanOrEqual(before+1);expect(state.reads).toBe(0);await expect(page.getByText('محترف المركز')).toHaveCount(0); +}); diff --git a/apps/web/e2e/portal-role-access.spec.js b/apps/web/e2e/portal-role-access.spec.js index 7cc182c82..25a307c2d 100644 --- a/apps/web/e2e/portal-role-access.spec.js +++ b/apps/web/e2e/portal-role-access.spec.js @@ -40,7 +40,13 @@ test('revoked protected role closes an already-open portal in the same session', await openWorkspaceSwitcher(page); await page.locator('#role-dialog [data-role="admin"]').click(); await expect.poll(()=>page.evaluate(()=>window.HydrolandPortalAccess.getCurrentRole())).toBe('admin'); + await expect(page.locator('.hl-role-dashboard[data-role="admin"]')).toBeVisible(); state.roles=[]; + // Returning to the visible tab refreshes authority and may remove the old + // dashboard switch while a pointer click is waiting for layout stability. + await page.evaluate(()=>document.dispatchEvent(new Event('visibilitychange'))); + await expect(page.locator('.hl-role-dashboard[data-role="admin"]')).toHaveCount(0); + await expect.poll(()=>page.evaluate(()=>window.HydrolandPortalAccess.getCurrentRole())).toBe('diver'); await openWorkspaceSwitcher(page); await expect(page.locator('#role-dialog')).toBeVisible(); await expectDenied(page); diff --git a/apps/web/e2e/professional-profile.spec.js b/apps/web/e2e/professional-profile.spec.js index 234a4d34f..6b36e2195 100644 --- a/apps/web/e2e/professional-profile.spec.js +++ b/apps/web/e2e/professional-profile.spec.js @@ -16,5 +16,10 @@ test('professional portal loads privacy-safe live profile metrics and clears the const dash=page.locator('.hl-role-dashboard[data-role="instructor"]');await expect(dash).toBeVisible();await expect(dash).toHaveAttribute('data-professional-profile-loaded','1'); const values=await dash.locator('.hl-role-tile b').allTextContents();expect(values.slice(0,4)).toEqual(['7','2','1','14']); await expect(dash).toContainText('مدرب اختبار');await expect(dash).not.toContainText('O+');await expect(dash).not.toContainText('private@example.invalid'); - state.active=false;await openWorkspaceSwitcher(page);await expect(page.locator('#role-dialog [data-role="instructor"]')).toBeDisabled();await expect(page.locator('.hl-role-dashboard[data-role="instructor"]')).toHaveCount(0); + state.active=false; + // Observe role loss before using the switcher: freshness can remove the dashboard + // during a pointer click, so its old switch button is no longer a stable target. + await page.evaluate(async()=>{await window.HydrolandPortalAccess.refreshPortalAccess();window.HydrolandPortalFreshness.enforce()}); + await expect(page.locator('.hl-role-dashboard[data-role="instructor"]')).toHaveCount(0); + await openWorkspaceSwitcher(page);await expect(page.locator('#role-dialog [data-role="instructor"]')).toBeDisabled(); }); diff --git a/apps/web/e2e/profile-load-coordination.spec.js b/apps/web/e2e/profile-load-coordination.spec.js new file mode 100644 index 000000000..ccc6ffa0d --- /dev/null +++ b/apps/web/e2e/profile-load-coordination.spec.js @@ -0,0 +1,48 @@ +import {test,expect} from '@playwright/test'; +const json=(route,body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); +const account={id:'profile-load-order',email:'profile-order@example.invalid',status:'ACTIVE',person:{firstName:'عضو',lastName:'اختبار'},roleAssignments:[{role:'DIVER',status:'ACTIVE'}]}; +const install=async page=>{ + await page.setViewportSize({width:390,height:844}); + await page.route('**/api/v1/**',route=>json(route,[])); + await page.route('**/api/v1/auth/google/config',route=>json(route,{enabled:false})); + await page.route(/\/api\/v1\/me$/,route=>json(route,account)); + await page.route(/\/api\/v1\/me\/diver-profile$/,route=>json(route,{message:'Unavailable'},503)); + await page.goto('/',{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>Boolean(window.HydrolandAuth&&window.HydrolandProfile)); + await page.evaluate(async()=>{window.HydrolandAuth.acceptSession({accessToken:'load-order-access',refreshToken:'load-order-refresh'},window.HydrolandAuth.beginAuthAttempt());await window.HydrolandProfile.load()}); + await page.locator('#profile-open').click(); + await page.locator('#profile-dialog [data-hl-action="diver-profile"]').click(); + await expect(page.locator('#hl-diver-editor [data-diver-retry]')).toBeVisible(); +}; + +for(const scenario of [{name:'successful older request',failOld:false,revoke:false},{name:'failed older request',failOld:true,revoke:false},{name:'session revocation',failOld:false,revoke:true}]){ + test(`profile retry waits for the replacement load: ${scenario.name}`,async({page})=>{ + await install(page); + const result=await page.evaluate(async({account,failOld,revoke})=>{ + const original=window.HydrolandAuth.authorizedFetch,pending=[]; + const response=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json'}}); + window.HydrolandAuth.authorizedFetch=(path,options)=>{ + if(path==='/me/diver-profile'&&(!options?.method||options.method==='GET'))return new Promise(resolve=>pending.push(resolve)); + if(path==='/me')return Promise.resolve(response(account)); + if(path==='/credentials')return Promise.resolve(response([])); + return original(path,options); + }; + try{ + const editor=document.getElementById('hl-diver-editor'),form=editor.querySelector('form'); + editor.querySelector('[data-diver-retry]').click(); + const newest=window.HydrolandProfile.load(); + if(pending.length!==2)throw new Error(`Expected two controlled requests, got ${pending.length}`); + pending[0](response(failOld?{message:'Unavailable'}:{profile:{primaryPhone:'0500000000'},equipment:[]},failOld?503:200)); + for(let i=0;i<20;i++)await Promise.resolve(); + const beforeReady=form.dataset.profileReady,beforeBusy=form.getAttribute('aria-busy'); + if(revoke)window.HydrolandAuth.terminateSession(); + pending[1](response({profile:{primaryPhone:'0552222222',emergencyName:'جهة اختبار',emergencyPhone:'0500000001',medicalFitnessStatus:'FIT'},equipment:[]})); + await newest;for(let i=0;i<20;i++)await Promise.resolve(); + return {beforeReady,beforeBusy,phone:form.elements.primaryPhone.value,ready:form.dataset.profileReady,available:window.HydrolandProfileData?.diverProfileAvailable,authenticated:window.HydrolandAuth.isAuthenticated()}; + }finally{window.HydrolandAuth.authorizedFetch=original;} + },{account,...scenario}); + expect(result.beforeReady).toBe('0');expect(result.beforeBusy).toBe('true'); + if(scenario.revoke){expect(result.authenticated).toBe(false);expect(result.available).toBeUndefined();expect(result.ready).toBe('0');} + else{expect(result.phone).toBe('0552222222');expect(result.ready).toBe('1');expect(result.available).toBe(true);} + }); +} diff --git a/apps/web/e2e/role-task-routing.spec.js b/apps/web/e2e/role-task-routing.spec.js index 1ed564d93..897a3a3d8 100644 --- a/apps/web/e2e/role-task-routing.spec.js +++ b/apps/web/e2e/role-task-routing.spec.js @@ -22,7 +22,8 @@ test('role workspace controls route to internal modules without observer loops', const bookings=centerDashboard.locator('[data-action-label="إدارة الحجوزات"]'); await expect(bookings).toBeEnabled(); await bookings.click(); - await expect.poll(()=>new URL(page.url()).hash).toBe('#trips'); + await expect(page.locator('#hl-center-operations')).toBeVisible(); + await expect(page.locator('#trips')).toBeHidden(); await page.evaluate(()=>{window.HydrolandMarineDocuments.open=()=>{document.body.dataset.marineDocumentsOpened='1'}}); await openWorkspaceSwitcher(page); diff --git a/apps/web/scripts/portal-access-validation.mjs b/apps/web/scripts/portal-access-validation.mjs index 22ff0a7ae..38f8d2676 100644 --- a/apps/web/scripts/portal-access-validation.mjs +++ b/apps/web/scripts/portal-access-validation.mjs @@ -1,10 +1,11 @@ import {readFile} from 'node:fs/promises'; import {resolve} from 'node:path'; -const [freshness,routing,auth]=await Promise.all([ +const [freshness,routing,auth,bootstrap]=await Promise.all([ readFile(resolve('src/hydroland-portal-access-freshness.js'),'utf8'), readFile(resolve('src/hydroland-role-task-routing.js'),'utf8'), - readFile(resolve('src/hydroland-auth.js'),'utf8') + readFile(resolve('src/hydroland-auth.js'),'utf8'), + readFile(resolve('src/app.js'),'utf8') ]); for(const marker of [ "authorizedFetch('/me')", @@ -15,11 +16,14 @@ for(const marker of [ "button.disabled=!allowed", "document.visibilityState!=='visible'", "event.stopImmediatePropagation()", + "'hydroland:profile-data-ready'", 'access.refreshPortalAccess=refreshPortalAccess', 'openRoleSwitcher', 'dialog?.showModal()' ])if(!freshness.includes(marker))throw new Error(`Missing portal-access freshness marker: ${marker}`); const authApiIndex=auth.indexOf('window.HydrolandAuth={'),initialAuthEvent=auth.indexOf('emitAuthChanged();',authApiIndex); if(authApiIndex<0||initialAuthEvent<0)throw new Error('Restored sessions must update visitor/workspace UI after auth bootstrap.'); -if(!routing.includes("freshnessScript.src='./hydroland-portal-access-freshness.js'"))throw new Error('Portal freshness runtime is not loaded after role task routing.'); +const freshnessModule="'hydroland-portal-access-freshness.js'",firstProtected="'hydroland-center-safety.js'"; +if(bootstrap.split(freshnessModule).length!==2||bootstrap.indexOf(freshnessModule)>bootstrap.indexOf(firstProtected))throw new Error('Portal freshness runtime must bootstrap once before protected center modules.'); +if(routing.includes("freshnessScript.src='./hydroland-portal-access-freshness.js'"))throw new Error('Portal freshness runtime must not be injected late by role task routing.'); console.log('Portal access validation passed: protected-role entry refreshes /me, fails closed, and revocation is enforced in-session.'); diff --git a/apps/web/scripts/portal-action-authorization-validation.mjs b/apps/web/scripts/portal-action-authorization-validation.mjs index 9b4924ecb..c685b5daa 100644 --- a/apps/web/scripts/portal-action-authorization-validation.mjs +++ b/apps/web/scripts/portal-action-authorization-validation.mjs @@ -1,14 +1,16 @@ import {readFile} from 'node:fs/promises'; import {resolve} from 'node:path'; -const [dashboards,routing]=await Promise.all([ +const [dashboards,routing,bootstrap,...centerModules]=await Promise.all([ readFile(resolve('src/hydroland-role-dashboards.js'),'utf8'), - readFile(resolve('src/hydroland-role-task-routing.js'),'utf8') + readFile(resolve('src/hydroland-role-task-routing.js'),'utf8'), + readFile(resolve('src/app.js'),'utf8'), + ...['safety','documents','equipment','customers','operations','team'].map(name=>readFile(resolve(`src/hydroland-center-${name}.js`),'utf8')) ]); for(const marker of [ 'authorizeRoleAction', - 'access.refreshPortalAccess', - 'HydrolandPortalFreshness?.enforce?.()', + 'access.authorizeRole', + "document.addEventListener('hydroland:role-changed'", 'getCurrentRole?.()', "document.addEventListener('hydroland:portal-cleared'", @@ -26,8 +28,45 @@ for(const marker of [ if(dashboards.includes("['المستندات والتراخيص','community'"))throw new Error('Portal document controls must not route to the community section.'); for(const marker of [ 'authorizeCurrentRole', - 'access.refreshPortalAccess', - 'HydrolandPortalFreshness?.enforce?.()', + 'access.authorizeRole', + 'button.onclick=async()=>' ])if(!routing.includes(marker))throw new Error(`Missing role-console action authorization marker: ${marker}`); -console.log('Portal action authorization validation passed: dashboard controls and role-console tasks reauthorize before execution.'); +if(bootstrap.split("'hydroland-portal-access-freshness.js'").length!==2||bootstrap.indexOf("'hydroland-portal-access-freshness.js'")>bootstrap.indexOf("'hydroland-center-safety.js'"))throw new Error('Portal freshness must bootstrap once before protected center modules.'); +for(const [module,label] of [["'hydroland-center-safety.js'",'Center safety'],["'hydroland-center-documents.js'",'Center documents'],["'hydroland-center-equipment.js'",'Center equipment'],["'hydroland-center-customers.js'",'Center customers'],["'hydroland-center-operations.js'",'Center operations'],["'hydroland-center-team.js'",'Center team']])if(bootstrap.split(module).length!==2||bootstrap.indexOf(module)>bootstrap.indexOf("'hydroland-role-dashboards.js'"))throw new Error(label+' must be registered once in the real bootstrap before dashboard actions.'); +for(const marker of [ + "if(role==='center'&&label==='السلامة')return 'center-safety'", + "if(role==='center'&&label==='المستندات والتراخيص')return 'center-documents'", + "if(role==='center'&&label==='المعدات والمخزون')return 'center-equipment'", + "if(role==='center'&&label==='العملاء')return 'center-customers'", + "if(role==='center'&&['إدارة الحجوزات','الرحلات'].includes(label))return 'center-operations'", + "if(role==='center'&&label==='محترفي الغوص')return 'center-team'", + "if(role==='center'&&['السلامة','تقارير السلامة'].includes(label))return 'center-safety'", + 'id=actionRoute(role,label,node.dataset.route)', + "if(activeRole==='center'&&['center-safety','center-documents','center-equipment','center-customers','center-operations','center-team'].includes(route))" +])if(!dashboards.includes(marker))throw new Error(`Missing scoped center route marker: ${marker}`); +if(!dashboards.includes("const trainingTarget=document.querySelector('.hl-training');window.HydrolandWorkspaceUI?.show?.(trainingTarget)"))throw new Error('Managed instructor training must route to the actual .hl-training service node.'); +if(!dashboards.includes("target=professionalTraining?document.querySelector('.hl-training'):targetFor(resolvedId)"))throw new Error('Professional training availability must be evaluated against the managed .hl-training service node.'); +if(!dashboards.includes("available=professionalTraining?Boolean(target&&window.HydrolandPortalAccess?.roleAllowed?.('instructor'))"))throw new Error('Professional training capability must not depend on current workspace visibility.'); +if(!dashboards.includes("resolvedId=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id)?'training':id,target=targetFor(resolvedId)"))throw new Error('Portal nav must resolve scoped training routes before the target existence guard.'); +if(!dashboards.includes("controllerRoute=route==='professional-profile'||['training-schedule','training-skills','training-certificates','training-earnings'].includes(route)"))throw new Error('Controller-owned portal routes must bypass the generic DOM target guard.'); +for(const marker of [ + "if(role==='instructor'&&label==='الجدول الزمني')return 'training-schedule'", + "if(role==='instructor'&&label==='التقييمات')return 'training-skills'", + "if(role==='instructor'&&label==='الشهادات')return 'training-certificates'", + "if(role==='instructor'&&label==='الإيرادات')return 'training-earnings'", + "if(role==='instructor'&&label==='الملف المهني')return 'professional-profile'" +])if(!dashboards.includes(marker))throw new Error(`Missing scoped professional route marker: ${marker}`); +for(const [index,source] of centerModules.entries()){ + const name=['safety','documents','equipment','customers','operations','team'][index]; + for(const marker of ['async function authorize','access()?.authorizeRole','if(!(await authorize(host,version,session)))return'])if(!source.includes(marker))throw new Error(`Center ${name} must refresh authoritative role state before scoped reads: missing ${marker}`); + for(const marker of ['getSessionVersion','hydroland:session-cleared','hydroland:portal-cleared'])if(!source.includes(marker))throw new Error(`Center ${name} must fence session/workspace lifecycle: missing ${marker}`); +} +const freshness=await readFile(resolve('src/hydroland-portal-access-freshness.js'),'utf8'); +for(const marker of ['const authorizeRole=async(role,context={})=>','await refreshPortalAccess()','session===auth.getSessionVersion?.()','access.authorizeRole=authorizeRole','HydrolandPortalFreshness={refresh:refreshPortalAccess,authorizeRole'])if(!freshness.includes(marker))throw new Error(`Missing centralized portal authorization marker: ${marker}`); +const workspace=await readFile(resolve('src/hydroland-workspace-ui.js'),'utf8'),workspaceCss=await readFile(resolve('src/hydroland-workspace-ui.css'),'utf8'); +if(!workspace.includes("while(serviceRoot.parentElement&&serviceRoot.parentElement!==main)serviceRoot=serviceRoot.parentElement"))throw new Error('Managed training must resolve its real direct child service root under #main.'); +if(!workspace.includes("const show=target=>{\n if(!auth()||!target)return false;\n setWorkspace();"))throw new Error('Workspace show must refresh role policy before checking service visibility.'); +if(!workspace.includes("if(selected?.isConnected&&isAuthed)"))throw new Error('Workspace policy refresh must preserve the selected managed service.'); +for(const marker of ["data-hl-workspace-service","hl-training-service","hl-managed-service-root"])if(!workspace.includes(marker)&&!workspaceCss.includes(marker))throw new Error(`Missing managed professional training workspace marker: ${marker}`); +console.log('Portal action authorization validation passed: centralized role authorization, registered scoped center modules and normalized action aliases.'); diff --git a/apps/web/src/app.js b/apps/web/src/app.js index d27ddde4f..aa21ad129 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -24,4 +24,4 @@ await loadScript('hydroland-experience.js'); await loadScript('hydroland-auth.js'); await loadScript('hydroland-workspace-ui.js'); await loadScript('hydroland-google-auth.js'); -for(const src of ['hydroland-bookings.js','hydroland-map.js','hydroland-account-center.js','hydroland-booking-availability.js','hydroland-profile-data.js','hydroland-messages.js','hydroland-dive-logs.js','hydroland-role-dashboards.js','hydroland-diver-dashboard.js','hydroland-marine-documents.js','hydroland-marine-readiness.js','hydroland-safety-ops.js','hydroland-safety-incidents.js','hydroland-trip-ops.js','hydroland-store.js','hydroland-equipment-admin-loader.js','hydroland-procurement.js','hydroland-logistics.js','hydroland-finance.js','hydroland-wallet-rewards.js','hydroland-members.js','hydroland-training.js','hydroland-professional-assignments.js','hydroland-community.js','hydroland-support.js','hydroland-admin.js','hydroland-store-admin.js','hydroland-theme-admin.js','hydroland-trip-admin.js','hydroland-safety-review.js','hydroland-safety-checklist.js','hydroland-dive-log-review.js','hydroland-crew-assignments.js','hydroland-public-discovery.js']){await loadScript(src)}})().catch(error=>{console.error('HYDROLAND bootstrap failed',error);document.dispatchEvent(new CustomEvent('hydroland:bootstrap-error',{detail:{message:error.message}}))}); +for(const src of ['hydroland-bookings.js','hydroland-map.js','hydroland-account-center.js','hydroland-booking-availability.js','hydroland-profile-data.js','hydroland-messages.js','hydroland-dive-logs.js','hydroland-portal-access-freshness.js','hydroland-center-safety.js','hydroland-center-documents.js','hydroland-center-equipment.js','hydroland-center-customers.js','hydroland-center-operations.js','hydroland-center-team.js','hydroland-role-dashboards.js','hydroland-diver-dashboard.js','hydroland-marine-documents.js','hydroland-marine-readiness.js','hydroland-safety-ops.js','hydroland-safety-incidents.js','hydroland-trip-ops.js','hydroland-store.js','hydroland-equipment-admin-loader.js','hydroland-procurement.js','hydroland-logistics.js','hydroland-finance.js','hydroland-wallet-rewards.js','hydroland-members.js','hydroland-training.js','hydroland-professional-assignments.js','hydroland-community.js','hydroland-support.js','hydroland-admin.js','hydroland-store-admin.js','hydroland-theme-admin.js','hydroland-trip-admin.js','hydroland-safety-review.js','hydroland-safety-checklist.js','hydroland-dive-log-review.js','hydroland-crew-assignments.js','hydroland-public-discovery.js']){await loadScript(src)}})().catch(error=>{console.error('HYDROLAND bootstrap failed',error);document.dispatchEvent(new CustomEvent('hydroland:bootstrap-error',{detail:{message:error.message}}))}); diff --git a/apps/web/src/hydroland-center-customers.js b/apps/web/src/hydroland-center-customers.js index 8fe4dceab..c59406f05 100644 --- a/apps/web/src/hydroland-center-customers.js +++ b/apps/web/src/hydroland-center-customers.js @@ -1 +1,25 @@ -(()=>{const auth=()=>window.HydrolandAuth;let section=null;const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??'');const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-customers hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

عملاء المركز

';document.getElementById('main')?.prepend(section);return section};async function open(){const host=ensure(),list=host.querySelector('[data-center-customers]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ تحميل العملاء...

';try{const response=await auth().authorizedFetch('/center/me/customers'),rows=await response.json().catch(()=>[]);if(!response.ok)throw new Error(rows?.message||'تعذر تحميل العملاء');list.innerHTML=rows.length?rows.map(row=>`
${esc(row.displayName)}${Number(row.bookingCount)} حجز
الحجوزات المؤكدة: ${Number(row.confirmedBookings)} · المقاعد: ${Number(row.totalSeats)} · آخر حجز: ${new Date(row.lastBookingAt).toLocaleDateString('ar-SA')}
`).join(''):'

لا يوجد عملاء مرتبطون برحلات هذا المركز.

'}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل العملاء')}

`}}window.HydrolandCenterCustomers=Object.freeze({open});})(); \ No newline at end of file +(()=>{ + const auth=()=>window.HydrolandAuth,access=()=>window.HydrolandPortalAccess;let section=null,viewVersion=0; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const date=value=>{if(!value)return '—';const parsed=new Date(value);return Number.isNaN(parsed.getTime())?'—':parsed.toLocaleDateString('ar-SA')}; + const count=value=>Number.isFinite(Number(value))?Math.max(0,Number(value)):0; + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-customers';section.id='hl-center-customers';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

عملاء المركز

ملخص العملاء المستمد من حجوزات رحلات المركز المُدار فقط.

';section.addEventListener('click',event=>{if(event.target.closest?.('[data-center-customers-retry]'))void open()});document.getElementById('main')?.prepend(section);return section}; + async function open(){ + if(!eligible()){clear();return}const host=ensure(),list=host.querySelector('[data-center-customers]'),version=++viewVersion,session=auth().getSessionVersion?.(); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ تحميل العملاء...

'; + try{if(!(await authorize(host,version,session)))return;const response=await auth().authorizedFetch('/center/me/customers'),rows=await response.json().catch(()=>null);if(!current(host,version,session))return;if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض عملاء هذا المركز.':rows?.message||'تعذر تحميل العملاء');if(!Array.isArray(rows))throw new Error('استجابة عملاء المركز غير مكتملة. أعد المحاولة.');list.innerHTML=rows.length?rows.map((row,index)=>`
${esc(row.displayName||'عميل')}${count(row.bookingCount)} حجز
الحجوزات المؤكدة: ${count(row.confirmedBookings)} · المقاعد: ${count(row.totalSeats)} · آخر حجز: ${esc(date(row.lastBookingAt))}
`).join(''):'

لا يوجد عملاء مرتبطون برحلات هذا المركز.

'} + catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل العملاء')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); + window.HydrolandCenterCustomers=Object.freeze({open}); +})(); \ No newline at end of file diff --git a/apps/web/src/hydroland-center-documents.js b/apps/web/src/hydroland-center-documents.js index d7dfaf37e..235d318a0 100644 --- a/apps/web/src/hydroland-center-documents.js +++ b/apps/web/src/hydroland-center-documents.js @@ -1 +1,124 @@ -(()=>{const auth=()=>window.HydrolandAuth;let section=null;const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??'');const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-documents hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

المستندات والتراخيص

';document.getElementById('main')?.prepend(section);return section};async function open(){const host=ensure(),list=host.querySelector('[data-center-documents]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ تحميل مستندات المركز...

';try{const response=await auth().authorizedFetch('/center/me/documents'),data=await response.json().catch(()=>null);if(!response.ok)throw new Error(data?.message||'تعذر تحميل المستندات');const docs=Array.isArray(data?.documents)?data.documents:[],licenses=Array.isArray(data?.licenseLike)?data.licenseLike:[];list.innerHTML=`

المستندات: ${docs.length} · التراخيص/التصاريح: ${licenses.length}

`+(docs.length?docs.map(row=>`
${esc(row.template?.name||row.referenceNumber)}${esc(row.referenceNumber)} · الإصدار ${Number(row.version)}
${esc(row.status)}
${esc(row.department)} · ${esc(row.template?.category||'DOCUMENT')}
`).join(''):'

لا توجد مستندات مرتبطة بهذا المركز.

')}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل المستندات')}

`}}window.HydrolandCenterDocuments=Object.freeze({open});})(); \ No newline at end of file +(()=>{ + const auth=()=>window.HydrolandAuth,access=()=>window.HydrolandPortalAccess; + let section=null,viewVersion=0; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const date=value=>{if(!value)return '—';const parsed=new Date(value);return Number.isNaN(parsed.getTime())?'—':parsed.toLocaleString('ar-SA')}; + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + const identityFields=subject=>``; + const createForm=units=>units.length?`
إنشاء سجل رخصة جديد
${identityFields('')}

`:'

إنشاء سجل جديد يتطلب وحدة إدارية نشطة مرتبطة بالمركز.

'; + async function saveRecord(form){ + if(form.dataset.busy)return; + const host=section,version=viewVersion,session=auth()?.getSessionVersion?.(),feedback=form.querySelector('[data-license-feedback]'),button=form.querySelector('[type="submit"]'); + const renewalId=form.dataset.licenseRenew,body={referenceNumber:form.elements.referenceNumber.value,subject:form.elements.subject.value}; + if(!renewalId){body.type=form.elements.type.value;body.unitId=form.elements.unitId.value} + form.dataset.busy='true';button.disabled=true; + try{ + if(!(await authorize(host,version,session)))return; + const endpoint='/center/me/licenses'+(renewalId?'/'+encodeURIComponent(renewalId)+'/renew':''); + const response=await auth().authorizedFetch(endpoint,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); + const data=await response.json().catch(()=>null);if(!current(host,version,session))return; + if(!response.ok)throw new Error(response.status===409?'الرقم المرجعي مستخدم أو السجل السابق ما زال مسودة. اختر رقمًا جديدًا أو أكمل المسودة الموجودة.':data?.message||'تعذر إنشاء المسودة.'); + await open(); + }catch(error){if(current(host,version,session))feedback.textContent=error.message||'تعذر إنشاء المسودة.'} + finally{delete form.dataset.busy;button.disabled=false} + } + const reviewControls=row=>{ + let html=''; + if(row.status==='DRAFT')html=row.licenseAssetId?`

تسجيل الرخصة يقفل المرفق والتواريخ. بعدها اختر وحدة المراجعة.

`:'

أرفق الرخصة وتواريخها قبل تسجيلها للمراجعة.

'; + const routes=Array.isArray(row.routings)?row.routings:[],units=Array.isArray(row.reviewUnits)?row.reviewUnits:[]; + if(row.status==='REGISTERED'&&!routes.some(x=>!x.decision))html+=units.length?`

`:'

الإرسال للمراجعة يتطلب وحدة نشطة أخرى داخل المركز.

'; + html+=routes.map(route=>{ + const options=Array.isArray(route.reviewerOptions)?route.reviewerOptions:[]; + let result=`

وحدة المراجعة: ${esc(route.unitName||'—')} · المراجع: ${esc(route.reviewerName||'غير معيّن')}

${route.decision==='APPROVE'?'قبول داخلي':route.decision==='REJECT'?'رفض داخلي':'قيد المراجعة'}

`; + if(!route.decision){ + result+=options.length?`

`:'

لا يوجد مراجع مؤهل مستقل متاح في بوابة هذا المركز.

'; + if(route.canDecide)result+=`

`; + } + return result+'
'; + }).join(''); + return html; + }; + async function reviewAction(form){ + if(form.dataset.busy)return; + const host=section,version=viewVersion,session=auth()?.getSessionVersion?.(),feedback=form.querySelector('[data-license-feedback]'),button=form.querySelector('[type="submit"]'); + const action=form.dataset.licenseReviewAction,id=encodeURIComponent(form.dataset.licenseTarget); + const config={register:{path:'/licenses/'+id+'/register',method:'PATCH',body:{}},route:{path:'/licenses/'+id+'/reviews',method:'POST',body:{toUnitId:form.elements.toUnitId?.value}},assign:{path:'/license-reviews/'+id+'/assign',method:'PATCH',body:{assigneeAccountId:form.elements.assigneeAccountId?.value}},decide:{path:'/license-reviews/'+id+'/decision',method:'PATCH',body:{decision:form.elements.decision?.value}}}[action]; + if(!config)return; + form.dataset.busy='true';button.disabled=true; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me'+config.path,{method:config.method,headers:{'content-type':'application/json'},body:JSON.stringify(config.body)}); + const data=await response.json().catch(()=>null);if(!current(host,version,session))return; + if(!response.ok)throw new Error(data?.message||'تعذر تنفيذ إجراء المراجعة. حدّث السجل وحاول مجددًا.'); + await open(); + }catch(error){if(current(host,version,session))feedback.textContent=error.message||'تعذر تنفيذ إجراء المراجعة.'} + finally{delete form.dataset.busy;button.disabled=false} + } + const licenseCard=row=>{ + const today=new Date().toISOString().slice(0,10),expires=row.licenseExpiresAt?.slice(0,10); + const validity=!expires?'الصلاحية غير موثقة':expirestoday?'لم تبدأ الصلاحية':'ضمن فترة الصلاحية'; + const routing=row.routings?.[0],review=!routing?'لم تُطلب مراجعة داخلية':routing.decision==='APPROVE'?'مقبولة بالمراجعة الداخلية':routing.decision==='REJECT'?'مرفوضة بالمراجعة الداخلية':'بانتظار المراجعة الداخلية'; + return `
${esc(row.subject||row.type||'سجل تنظيمي')}${esc(row.referenceNumber||'بدون رقم مرجعي')} · ${esc(row.type||'RECORD')}
${esc(row.status||'—')}

${esc(validity)} · ${esc(review)}

الإصدار: ${esc(row.licenseIssuedAt?.slice(0,10)||'—')} · الانتهاء: ${esc(expires||'—')}

${row.licenseAssetId?``:'لا يوجد مرفق مرتبط'}

${row.status==='DRAFT'?`

`:`المرفق مقفل بعد تسجيل السجل.
تجديد بسجل جديد

تبقى الرخصة السابقة كما هي. أرفق نسخة الرخصة المجددة بعد إنشاء المسودة.

${identityFields(row.subject)}

`}آخر تحديث: ${esc(date(row.updatedAt||row.createdAt))}

${reviewControls(row)}
`; + }; + async function attach(form){ + if(form.dataset.busy)return; + const host=section,version=viewVersion,session=auth()?.getSessionVersion?.(),feedback=form.querySelector('[data-license-feedback]'),button=form.querySelector('button[type="submit"]'); + const file=form.elements.file.files[0],issuedAt=form.elements.issuedAt.value,expiresAt=form.elements.expiresAt.value,id=form.dataset.licenseAttachment; + form.dataset.busy='true';button.disabled=true; + try{ + if(!file||!file.size||file.size>2000000)throw new Error('اختر ملفًا بحجم لا يتجاوز 2 ميجابايت.'); + if(!issuedAt||!expiresAt||expiresAt<=issuedAt)throw new Error('يجب أن يكون الانتهاء بعد الإصدار.'); + const base64=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=()=>reject(new Error('تعذرت قراءة الملف.'));reader.readAsDataURL(file)}); + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/licenses/'+encodeURIComponent(id)+'/attachment',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({mimeType:file.type,base64,issuedAt,expiresAt})}); + const data=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(data?.message||'تعذر حفظ المرفق.'); + await open(); + }catch(error){if(current(host,version,session))feedback.textContent=error.message||'تعذر حفظ المرفق.'} + finally{delete form.dataset.busy;button.disabled=false} + } + async function download(button){ + if(button.disabled)return; + const host=section,version=viewVersion,session=auth()?.getSessionVersion?.(),feedback=button.closest('article').querySelector('[data-license-download-feedback]'); + button.disabled=true; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/licenses/'+encodeURIComponent(button.dataset.licenseDownload)+'/attachment'); + if(!current(host,version,session))return; + if(!response.ok)throw new Error('تعذر تنزيل المرفق.'); + const blob=await response.blob();if(!current(host,version,session))return; + const extension=blob.type==='application/pdf'?'pdf':blob.type==='image/png'?'png':'jpg'; + const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='license.'+extension;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); + }catch(error){if(current(host,version,session))feedback.textContent=error.message||'تعذر تنزيل المرفق.'} + finally{button.disabled=false} + } + const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-documents';section.id='hl-center-documents';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

المستندات والتراخيص

سجلات المركز التنظيمية والأصول المرفوعة ضمن نطاق المركز المُدار فقط.

';section.addEventListener('click',event=>{if(event.target.closest?.('[data-center-documents-retry]'))void open();const button=event.target.closest?.('[data-license-download]');if(button)void download(button)});section.addEventListener('submit',event=>{const reviewForm=event.target.closest?.('[data-license-review-action]');if(reviewForm){event.preventDefault();void reviewAction(reviewForm);return}const form=event.target.closest?.('[data-license-attachment]');if(form){event.preventDefault();void attach(form);return}const recordForm=event.target.closest?.('[data-license-create],[data-license-renew]');if(recordForm){event.preventDefault();void saveRecord(recordForm)}});document.getElementById('main')?.prepend(section);return section}; + async function open(){ + if(!eligible()){clear();return;} + const host=ensure(),list=host.querySelector('[data-center-documents]'),version=++viewVersion,session=auth().getSessionVersion?.(); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ تحميل مستندات المركز...

'; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/documents'),data=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض مستندات هذا المركز.':data?.message||'تعذر تحميل المستندات'); + if(!Array.isArray(data?.assets)||!Array.isArray(data?.licenses))throw new Error('استجابة مستندات المركز غير مكتملة. أعد المحاولة.'); + const assets=data.assets,licenses=data.licenses,units=Array.isArray(data.units)?data.units:[]; + list.innerHTML=createForm(units)+`

الملفات المرفوعة: ${assets.length} · التراخيص والتصاريح: ${licenses.length}

التراخيص والتصاريح

حالة المراجعة داخلية، ولا تمثل تحققًا من الجهة المصدرة للرخصة.

`+ + (licenses.length?licenses.map(licenseCard).join(''):'

لا توجد تراخيص أو تصاريح مرتبطة بهذا المركز.

')+ + '

الملفات المرفوعة

'+(assets.length?assets.map(row=>`
${esc(row.kind||'مستند')}${esc(row.mimeType||'ملف')} · ${Number.isFinite(Number(row.byteSize))?Number(row.byteSize).toLocaleString('ar-SA')+' بايت':'الحجم غير متاح'}
محفوظ
تاريخ الإضافة: ${esc(date(row.createdAt))}
`).join(''):'

لا توجد ملفات مرفوعة مرتبطة بهذا المركز.

'); + }catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل المستندات')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); + window.HydrolandCenterDocuments=Object.freeze({open}); +})(); \ No newline at end of file diff --git a/apps/web/src/hydroland-center-equipment.js b/apps/web/src/hydroland-center-equipment.js index 9d4a25252..eccb8a1fd 100644 --- a/apps/web/src/hydroland-center-equipment.js +++ b/apps/web/src/hydroland-center-equipment.js @@ -1,6 +1,88 @@ -(()=>{const auth=()=>window.HydrolandAuth;let section=null;const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??'');const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-equipment hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

المعدات والمخزون

';document.getElementById('main')?.prepend(section);return section}; -const card=row=>`
${esc(row.resourceName)}${esc(row.assetCode)}${row.serialNumber?' · S/N '+esc(row.serialNumber):''}
${esc(row.stockStatus)}
الموقع: ${esc(row.location||'غير محدد')} · SKU: ${esc(row.sku||'—')}
`; -async function open(){const host=ensure(),list=host.querySelector('[data-center-equipment]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ تحميل معدات المركز...

';try{const response=await auth().authorizedFetch('/center/me/equipment'),rows=await response.json().catch(()=>[]);if(!response.ok)throw new Error(rows?.message||'تعذر تحميل المعدات');list.innerHTML=rows.length?rows.map(card).join(''):'

لا توجد معدات منسوبة لهذا المركز حاليًا.

'}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل المعدات')}

`}} -const host=ensure();host.querySelector('[data-equipment-lookup]').addEventListener('submit',async event=>{event.preventDefault();const code=new FormData(event.currentTarget).get('code');try{const response=await auth().authorizedFetch('/center/me/equipment/lookup/'+encodeURIComponent(String(code))),row=await response.json().catch(()=>null);if(!response.ok)throw new Error(row?.message||'لم يتم العثور على الأصل');host.querySelector('[data-center-equipment]').innerHTML=card(row)}catch(error){host.querySelector('[data-center-equipment]').innerHTML=`

${esc(error instanceof Error?error.message:'تعذر البحث')}

`}}); -host.addEventListener('click',async event=>{const button=event.target.closest?.('[data-move]');if(!button)return;const article=button.closest('[data-center-equipment-id]'),id=article?.dataset.centerEquipmentId;if(!id)return;button.disabled=true;try{const response=await auth().authorizedFetch('/center/me/equipment/'+encodeURIComponent(id)+'/move',{method:'PATCH',body:JSON.stringify({movementType:button.dataset.move})}),body=await response.json().catch(()=>null);if(!response.ok)throw new Error(body?.message||'تعذر تحديث حالة الأصل');await open()}catch(error){button.disabled=false;article.insertAdjacentHTML('beforeend',`

${esc(error instanceof Error?error.message:'تعذر تحديث الأصل')}

`)}}); -window.HydrolandCenterEquipment=Object.freeze({open});})(); \ No newline at end of file +(()=>{ + const auth=()=>window.HydrolandAuth,access=()=>window.HydrolandPortalAccess; + let section=null,viewVersion=0; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + function ensure(){ + if(section?.isConnected)return section; + const host=document.createElement('section'); + host.className='hl-center-equipment';host.id='hl-center-equipment';host.hidden=true; + host.innerHTML='
DIVE CENTER · مركز الغوص

المعدات والمخزون

الأصول المنسوبة للمركز وحالتها التشغيلية فقط.

'; + // Every recreated workspace owns its listeners; never bind only the first instance. + host.querySelector('[data-equipment-lookup]').addEventListener('submit',lookup); + host.addEventListener('click',onClick); + section=host;document.getElementById('main')?.prepend(host);return host; + } + const card=row=>`
${esc(row.resourceName||'معدة')}${esc(row.assetCode||'—')}${row.serialNumber?' · S/N '+esc(row.serialNumber):''}
${esc(row.stockStatus||'—')}
الموقع: ${esc(row.location||'غير محدد')} · SKU: ${esc(row.sku||'—')}
`; + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + async function open(){ + if(!eligible()){clear();return} + const host=ensure(),list=host.querySelector('[data-center-equipment]'),version=++viewVersion,session=auth().getSessionVersion?.(); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ تحميل معدات المركز...

'; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/equipment'),rows=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية إدارة معدات هذا المركز.':rows?.message||'تعذر تحميل المعدات'); + if(!Array.isArray(rows))throw new Error('استجابة معدات المركز غير مكتملة. أعد المحاولة.'); + list.innerHTML=rows.length?rows.map(card).join(''):'

لا توجد معدات منسوبة لهذا المركز حاليًا.

'; + }catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل المعدات')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + async function lookup(event){ + event.preventDefault(); + // currentTarget is reset after event dispatch, so capture form data before awaiting. + const form=event.currentTarget,host=form.closest('#hl-center-equipment'); + const code=String(new FormData(form).get('code')||'').trim();if(!code||!host||!eligible())return; + const version=++viewVersion,session=auth().getSessionVersion?.(),list=host.querySelector('[data-center-equipment]'); + if(!(await authorize(host,version,session)))return; + list.setAttribute('aria-busy','true'); + try{ + const response=await auth().authorizedFetch('/center/me/equipment/lookup/'+encodeURIComponent(code)),row=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(row?.message||'لم يتم العثور على الأصل'); + if(!row?.resourceId)throw new Error('استجابة الأصل غير مكتملة.'); + list.innerHTML=card(row); + }catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر البحث')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + async function onClick(event){ + const host=event.currentTarget; + if(event.target.closest?.('[data-center-equipment-retry]')){void open();return} + const article=event.target.closest?.('[data-center-equipment-id]');if(!article||!host.contains(article))return; + const button=event.target.closest?.('[data-equipment-history-button],[data-move]');if(!button||button.disabled)return; + const id=article.dataset.centerEquipmentId,version=viewVersion,session=auth()?.getSessionVersion?.(); + const live=()=>current(host,version,session)&&article.isConnected; + button.disabled=true; + try{ + if(!(await authorize(host,version,session))||!live())return; + if(button.hasAttribute('data-equipment-history-button')){ + const response=await auth().authorizedFetch('/center/me/equipment/'+encodeURIComponent(id)+'/history'),rows=await response.json().catch(()=>null); + if(!live())return; + if(!response.ok)throw new Error(rows?.message||'تعذر تحميل سجل الحركة'); + if(!Array.isArray(rows))throw new Error('استجابة سجل الحركة غير مكتملة.'); + article.querySelector('[data-equipment-history]').innerHTML=rows.length?rows.map(row=>`
${esc(row.movementType)}${esc(row.toLocation||row.fromLocation||'بدون موقع')}
`).join(''):'

لا توجد حركات مسجلة.

'; + }else{ + const response=await auth().authorizedFetch('/center/me/equipment/'+encodeURIComponent(id)+'/move',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({movementType:button.dataset.move})}),body=await response.json().catch(()=>null); + if(!live())return; + if(!response.ok)throw new Error(body?.message||'تعذر تحديث حالة الأصل'); + await open(); + } + }catch(error){ + if(live()){ + const message=`

${esc(error instanceof Error?error.message:'تعذر تنفيذ إجراء المعدة')}

`; + if(button.hasAttribute('data-equipment-history-button'))article.querySelector('[data-equipment-history]').innerHTML=message; + else article.insertAdjacentHTML('beforeend',message); + } + }finally{button.disabled=false} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); + window.HydrolandCenterEquipment=Object.freeze({open}); +})(); diff --git a/apps/web/src/hydroland-center-operations.js b/apps/web/src/hydroland-center-operations.js index fe4da61fd..4e5440035 100644 --- a/apps/web/src/hydroland-center-operations.js +++ b/apps/web/src/hydroland-center-operations.js @@ -1,9 +1,59 @@ (()=>{ - const auth=()=>window.HydrolandAuth;let section=null; - const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??''); - const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-operations hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

الرحلات والحجوزات

';document.getElementById('main')?.prepend(section);return section}; - async function bookings(tripId,article){const response=await auth().authorizedFetch('/center/me/trips/'+encodeURIComponent(tripId)+'/bookings'),rows=await response.json().catch(()=>[]);if(!response.ok)throw new Error(rows?.message||'تعذر تحميل الحجوزات');let box=article.querySelector('[data-bookings]');if(!box){box=document.createElement('div');box.dataset.bookings='';article.append(box)}box.innerHTML=rows.length?rows.map(row=>`
حجز ${esc(row.id.slice(0,8))}${esc(row.status)} · ${Number(row.seats)} مقعد${esc([row.account?.person?.firstName,row.account?.person?.lastName].filter(Boolean).join(' ')||'عميل')} · المشاركون: ${row.participants?.length||0}
`).join(''):'

لا توجد حجوزات لهذه الرحلة.

'} - async function open(){const host=ensure(),list=host.querySelector('[data-center-ops-list]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ تحميل رحلات المركز...

';try{const response=await auth().authorizedFetch('/center/me/trips'),rows=await response.json().catch(()=>[]);if(!response.ok)throw new Error(rows?.message||'تعذر تحميل الرحلات');list.innerHTML=rows.length?rows.map(row=>`
${esc(row.title)}${new Date(row.startsAt).toLocaleString('ar-SA')} · ${esc(row.type)}
${esc(row.status)}
السعة: ${Number(row.capacity)} · الحجوزات: ${Number(row._count?.bookings||0)}
`).join(''):'

لا توجد رحلات مرتبطة بهذا المركز حاليًا.

'}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل الرحلات')}

`}} - ensure().addEventListener('click',async event=>{const button=event.target.closest?.('[data-center-bookings]');if(!button)return;const article=button.closest('[data-center-trip]');button.disabled=true;try{await bookings(article.dataset.centerTrip,article)}catch(error){article.insertAdjacentHTML('beforeend',`

${esc(error instanceof Error?error.message:'تعذر تحميل الحجوزات')}

`)}finally{button.disabled=false}}); + const auth=()=>window.HydrolandAuth,access=()=>window.HydrolandPortalAccess; + let section=null,viewVersion=0; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const date=value=>{if(!value)return '—';const parsed=new Date(value);return Number.isNaN(parsed.getTime())?'—':parsed.toLocaleString('ar-SA')}; + const count=value=>Number.isFinite(Number(value))?Math.max(0,Number(value)):0; + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + function ensure(){ + if(section?.isConnected)return section; + const host=document.createElement('section');host.className='hl-center-operations';host.id='hl-center-operations';host.hidden=true; + host.innerHTML='
DIVE CENTER · مركز الغوص

الرحلات والحجوزات

رحلات المركز المُدار وحجوزاتها فقط.

'; + // The listener belongs to this instance, including after portal/session cleanup. + host.addEventListener('click',onClick); + section=host;document.getElementById('main')?.prepend(host);return host; + } + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + async function bookings(tripId,article,host,version,session){ + if(!(await authorize(host,version,session))||!article.isConnected)return; + const response=await auth().authorizedFetch('/center/me/trips/'+encodeURIComponent(tripId)+'/bookings'),rows=await response.json().catch(()=>null); + if(!current(host,version,session)||!article.isConnected)return; + if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض حجوزات هذه الرحلة.':response.status===404?'الرحلة غير موجودة ضمن المركز المُدار.':rows?.message||'تعذر تحميل الحجوزات'); + if(!Array.isArray(rows))throw new Error('استجابة حجوزات الرحلة غير مكتملة.'); + let box=article.querySelector('[data-bookings]');if(!box){box=document.createElement('div');box.dataset.bookings='';article.append(box)} + box.innerHTML=rows.length?rows.map(row=>`
حجز ${esc(String(row.id||'').slice(0,8)||'—')}${esc(row.status||'—')} · ${count(row.seats)} مقعد${esc([row.account?.person?.firstName,row.account?.person?.lastName].filter(Boolean).join(' ').trim()||'عميل')} · المشاركون: ${Array.isArray(row.participants)?row.participants.length:0}
`).join(''):'

لا توجد حجوزات لهذه الرحلة.

'; + } + async function open(){ + if(!eligible()){clear();return} + const host=ensure(),list=host.querySelector('[data-center-ops-list]'),version=++viewVersion,session=auth().getSessionVersion?.(); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ تحميل رحلات المركز...

'; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/trips'),rows=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض رحلات هذا المركز.':rows?.message||'تعذر تحميل الرحلات'); + if(!Array.isArray(rows))throw new Error('استجابة رحلات المركز غير مكتملة. أعد المحاولة.'); + list.innerHTML=rows.length?rows.map(row=>`
${esc(row.title||'رحلة المركز')}${esc(date(row.startsAt))} · ${esc(row.type||'—')}
${esc(row.status||'—')}
السعة: ${count(row.capacity)} · الحجوزات: ${count(row._count?.bookings)}
`).join(''):'

لا توجد رحلات مرتبطة بهذا المركز حاليًا.

'; + }catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل الرحلات')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + async function onClick(event){ + const host=event.currentTarget; + if(event.target.closest?.('[data-center-operations-retry]')){void open();return} + const button=event.target.closest?.('[data-center-bookings]');if(!button||button.disabled)return; + const article=button.closest('[data-center-trip]');if(!article||!host.contains(article))return; + const version=viewVersion,session=auth()?.getSessionVersion?.();button.disabled=true; + try{await bookings(article.dataset.centerTrip,article,host,version,session)} + catch(error){if(current(host,version,session)&&article.isConnected)article.insertAdjacentHTML('beforeend',`

${esc(error instanceof Error?error.message:'تعذر تحميل الحجوزات')}

`)} + finally{button.disabled=false} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); window.HydrolandCenterOperations=Object.freeze({open}); })(); diff --git a/apps/web/src/hydroland-center-safety.js b/apps/web/src/hydroland-center-safety.js index c253ab64c..dbd2705ef 100644 --- a/apps/web/src/hydroland-center-safety.js +++ b/apps/web/src/hydroland-center-safety.js @@ -1 +1,45 @@ -(()=>{const auth=()=>window.HydrolandAuth;let section=null;const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??'');const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-safety hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

السلامة

';document.getElementById('main')?.prepend(section);return section};async function open(){const host=ensure(),list=host.querySelector('[data-center-safety]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ تحميل السلامة...

';try{const response=await auth().authorizedFetch('/center/me/safety'),data=await response.json().catch(()=>null);if(!response.ok)throw new Error(data?.message||'تعذر تحميل السلامة');const checks=data?.checklists||[],incidents=data?.incidents||[];list.innerHTML=`

قوائم السلامة: ${checks.length} · الحوادث: ${incidents.length}

الحوادث

`+(incidents.length?incidents.map(row=>`
${esc(row.title)}${esc(row.trip?.title||'رحلة المركز')} · ${esc(row.locationName||'')}
${esc(row.severity)} · ${esc(row.status)}
`).join(''):'

لا توجد حوادث مرتبطة برحلات المركز.

')+'

قوائم السلامة

'+(checks.length?checks.map(row=>`
${esc(row.trip?.title||row.tripId)}${esc(row.decision)}
${esc(row.notes||'')}
`).join(''):'

لا توجد قوائم سلامة مرتبطة برحلات المركز.

')}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل السلامة')}

`}}window.HydrolandCenterSafety=Object.freeze({open});})(); \ No newline at end of file +(()=>{ + const auth=()=>window.HydrolandAuth; + const access=()=>window.HydrolandPortalAccess; + let section=null,viewVersion=0; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const date=value=>{if(!value)return '—';const parsed=new Date(value);return Number.isNaN(parsed.getTime())?'—':parsed.toLocaleString('ar-SA')}; + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + const ensure=()=>{ + if(section?.isConnected)return section; + section=document.createElement('section');section.className='hl-center-safety';section.id='hl-center-safety';section.hidden=true; + section.innerHTML='
DIVE CENTER · مركز الغوص

السلامة

عرض سجلات رحلات المركز فقط؛ الاعتماد وتغيير الحالة عبر مسار المراجعة المختص.

'; + section.addEventListener('click',event=>{if(event.target.closest?.('[data-center-safety-retry]'))void open()}); + document.getElementById('main')?.prepend(section);return section; + }; + async function open(){ + if(!eligible()){clear();return;} + const host=ensure(),list=host.querySelector('[data-center-safety]'); + const version=++viewVersion,session=auth().getSessionVersion(); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ تحميل سجلات السلامة...

'; + try{ + if(!(await authorize(host,version,session)))return; + const response=await auth().authorizedFetch('/center/me/safety'); + const data=await response.json().catch(()=>null); + if(!current(host,version,session))return; + if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض سجلات هذا المركز.':data?.message||'تعذر تحميل السلامة'); + if(!Array.isArray(data?.checklists)||!Array.isArray(data?.incidents))throw new Error('استجابة سجلات السلامة غير مكتملة. أعد المحاولة.'); + const checks=data.checklists,incidents=data.incidents; + list.innerHTML=`

قوائم الفحص: ${checks.length} · الحوادث: ${incidents.length}

قوائم الفحص

`+ + (checks.length?checks.map(row=>`
${esc(row.trip?.title||'رحلة المركز')}${esc(row.decision||'—')}
آخر تحديث: ${esc(date(row.decidedAt||row.updatedAt))}${row.notes?`

${esc(row.notes)}

`:''}
`).join(''):'

لا توجد قوائم فحص مرتبطة برحلات المركز.

')+ + '

الحوادث

'+(incidents.length?incidents.map(row=>`
${esc(row.title)}${esc(row.severity)} · ${esc(row.status)}
${esc(row.trip?.title||'رحلة المركز')} · ${esc(row.locationName||'')} · ${esc(date(row.createdAt))}
`).join(''):'

لا توجد حوادث مرتبطة برحلات المركز.

'); + }catch(error){ + if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر تحميل السلامة')}

`; + }finally{if(current(host,version,session))list.removeAttribute('aria-busy');} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); + window.HydrolandCenterSafety=Object.freeze({open}); +})(); diff --git a/apps/web/src/hydroland-center-team.js b/apps/web/src/hydroland-center-team.js index 439a0e9cf..b9c1d117f 100644 --- a/apps/web/src/hydroland-center-team.js +++ b/apps/web/src/hydroland-center-team.js @@ -1,7 +1,29 @@ (()=>{ - const auth=()=>window.HydrolandAuth;let section=null; - const esc=v=>window.HydrolandUI?.esc?.(v)??String(v??''); - const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-team hl-workspace-hidden';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

الطاقم ومحترفو الغوص

';document.getElementById('main')?.prepend(section);return section}; - async function open(mode='team'){const host=ensure(),list=host.querySelector('[data-center-team-list]');window.HydrolandWorkspaceUI?.show?.(host);list.innerHTML='

جارٍ التحميل...

';try{const response=await auth().authorizedFetch(mode==='professionals'?'/center/me/professionals':'/center/me/team'),rows=await response.json().catch(()=>[]);if(!response.ok)throw new Error(rows?.message||'تعذر التحميل');list.innerHTML=rows.length?rows.map(row=>`
${esc(row.displayName||row.person?.displayName)}${esc(row.headline||row.person?.headline||row.role||'عضو المركز')}
${esc(row.status||row.professional?.instructorStatus||'ACTIVE')}
الشهادات الموثقة: ${Number(row.verifiedCredentials??row.professional?.verifiedCredentials??0)}${row.assignedTrainingCount!==undefined?' · التكليفات التدريبية: '+Number(row.assignedTrainingCount):''}
`).join(''):'

لا توجد سجلات في هذا القسم حاليًا.

'}catch(error){list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر التحميل')}

`}} + const auth=()=>window.HydrolandAuth,access=()=>window.HydrolandPortalAccess;let section=null,viewVersion=0,lastMode='team'; + const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); + const eligible=()=>Boolean(auth()?.isAuthenticated?.()&&access()?.roleAllowed?.('center')&&access()?.getCurrentRole?.()==='center'); + const count=value=>Number.isFinite(Number(value))?Math.max(0,Number(value)):0; + const clear=()=>{viewVersion++;section?.remove();section=null}; + const current=(host,version,session)=>host===section&&host.isConnected&&version===viewVersion&&eligible()&&session===auth()?.getSessionVersion?.(); + async function authorize(host,version,session){ + const centralized=access()?.authorizeRole;if(typeof centralized!=='function'){clear();return false} + const valid=await centralized('center',{sessionVersion:session,isCurrent:()=>host===section&&host.isConnected&&version===viewVersion}); + if(!valid||!current(host,version,session)){if(host===section&&host.isConnected)clear();return false}return true + } + const ensure=()=>{if(section?.isConnected)return section;section=document.createElement('section');section.className='hl-center-team';section.id='hl-center-team';section.hidden=true;section.innerHTML='
DIVE CENTER · مركز الغوص

الطاقم

أعضاء المركز وحالة عضويتهم.

';section.addEventListener('click',event=>{const mode=event.target.closest?.('[data-center-team-mode]')?.dataset.centerTeamMode;if(mode)void open(mode);if(event.target.closest?.('[data-center-team-retry]'))void open(lastMode)});document.getElementById('main')?.prepend(section);return section}; + async function open(mode='team'){ + mode=mode==='professionals'?'professionals':'team';lastMode=mode;if(!eligible()){clear();return} + const host=ensure(),list=host.querySelector('[data-center-team-list]'),version=++viewVersion,session=auth().getSessionVersion?.(); + host.querySelector('[data-center-team-title]').textContent=mode==='professionals'?'محترفو الغوص':'الطاقم'; + host.querySelector('[data-center-team-description]').textContent=mode==='professionals'?'محترفو الغوص النشطون المرتبطون بالمركز وتكليفاتهم التدريبية.':'أعضاء المركز وحالة عضويتهم الفعلية.'; + host.querySelectorAll('[data-center-team-mode]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.centerTeamMode===mode))); + host.hidden=false;window.HydrolandWorkspaceUI?.show?.(host);list.setAttribute('aria-busy','true');list.innerHTML='

جارٍ التحميل...

'; + try{if(!(await authorize(host,version,session)))return;const response=await auth().authorizedFetch(mode==='professionals'?'/center/me/professionals':'/center/me/team'),rows=await response.json().catch(()=>null);if(!current(host,version,session))return;if(!response.ok)throw new Error(response.status===403?'لا تملك صلاحية عرض أعضاء هذا المركز.':rows?.message||'تعذر التحميل');if(!Array.isArray(rows))throw new Error('استجابة أعضاء المركز غير مكتملة. أعد المحاولة.'); + list.innerHTML=rows.length?rows.map((row,index)=>`
${esc(row.displayName||row.person?.displayName||'عضو المركز')}${esc(row.headline||row.person?.headline||row.role||'عضو المركز')}
${esc(row.status||row.professional?.instructorStatus||'ACTIVE')}
الشهادات الموثقة: ${count(row.verifiedCredentials??row.professional?.verifiedCredentials)}${row.assignedTrainingCount!==undefined?' · التكليفات التدريبية: '+count(row.assignedTrainingCount):''}
`).join(''):'

لا توجد سجلات في هذا القسم حاليًا.

'} + catch(error){if(current(host,version,session))list.innerHTML=`

${esc(error instanceof Error?error.message:'تعذر التحميل')}

`} + finally{if(current(host,version,session))list.removeAttribute('aria-busy')} + } + for(const name of ['hydroland:session-cleared','hydroland:portal-cleared'])document.addEventListener(name,clear); + for(const name of ['hydroland:auth-changed','hydroland:role-changed','hydroland:profile-data-ready'])document.addEventListener(name,()=>{if(!eligible())clear()}); window.HydrolandCenterTeam=Object.freeze({open}); -})(); +})(); \ No newline at end of file diff --git a/apps/web/src/hydroland-portal-access-freshness.js b/apps/web/src/hydroland-portal-access-freshness.js index 69d6ec0f5..a73d90051 100644 --- a/apps/web/src/hydroland-portal-access-freshness.js +++ b/apps/web/src/hydroland-portal-access-freshness.js @@ -29,6 +29,20 @@ })(); try{return await refreshPromise}finally{refreshPromise=null} }; + const authorizeRole=async(role,context={})=>{ + const session=context.sessionVersion??auth.getSessionVersion?.(); + const stillCurrent=()=>authenticated()&&session===auth.getSessionVersion?.()&&(!context.isCurrent||context.isCurrent()); + if(role==='diver')return stillCurrent(); + if(!stillCurrent())return false; + const refreshed=await refreshPortalAccess(); + if(!refreshed||!stillCurrent())return false; + if(!access.roleAllowed(role)){ + enforceCurrentRole(); + access.clearProtectedPortal?.(); + return false; + } + return true; + }; const syncRoleOptions=()=>{ document.querySelectorAll('#role-dialog [data-role]').forEach(button=>{ const allowed=access.roleAllowed(button.dataset.role); @@ -54,6 +68,7 @@ dialog?.showModal(); }; access.refreshPortalAccess=refreshPortalAccess; + access.authorizeRole=authorizeRole; roleSwitch?.addEventListener('click',async event=>{ if(replaySwitch)return; if(!authenticated()){syncRoleOptions();return} @@ -84,6 +99,7 @@ syncRoleOptions(); }); document.addEventListener('hydroland:auth-changed',()=>queueMicrotask(syncRoleOptions)); + document.addEventListener('hydroland:profile-data-ready',()=>queueMicrotask(()=>{enforceCurrentRole();syncRoleOptions()})); syncRoleOptions(); - window.HydrolandPortalFreshness={refresh:refreshPortalAccess,sync:syncRoleOptions,enforce:enforceCurrentRole,openRoleSwitcher}; + window.HydrolandPortalFreshness={refresh:refreshPortalAccess,authorizeRole,sync:syncRoleOptions,enforce:enforceCurrentRole,openRoleSwitcher}; })(); diff --git a/apps/web/src/hydroland-profile-data.js b/apps/web/src/hydroland-profile-data.js index 90839614e..0fb2d2da4 100644 --- a/apps/web/src/hydroland-profile-data.js +++ b/apps/web/src/hydroland-profile-data.js @@ -1,7 +1,7 @@ (()=>{ const auth=()=>window.HydrolandAuth; const esc=value=>window.HydrolandUI.esc(value); - let loadVersion=0; + let loadVersion=0,latestLoad=null;const loadPromises=new Map(); const toast=message=>{const t=document.getElementById('toast');if(!t)return;t.textContent=message;t.classList.add('visible');setTimeout(()=>t.classList.remove('visible'),2200)}; const request=async(path,options={})=>{ const client=auth()?.authorizedFetch;if(!client)throw new Error('AUTH_REQUIRED'); @@ -89,8 +89,35 @@ async function load(){ if(!auth()?.isAuthenticated?.()){window.HydrolandProfileData=undefined;return;} const version=++loadVersion,sessionVersion=auth().getSessionVersion(); - try{const [profile,credentials,diverResult]=await Promise.all([request('/me'),request('/credentials'),request('/me/diver-profile').then(data=>({ok:true,data})).catch(error=>({ok:false,error}))]);if(version!==loadVersion||sessionVersion!==auth().getSessionVersion()||!auth()?.isAuthenticated?.())return;const normalizedProfile=normalizeProfile(profile);renderProfile(normalizedProfile,Array.isArray(credentials)?credentials:[]);const diver=diverResult.ok?diverResult.data:null;window.HydrolandProfileData={profile:normalizedProfile,credentials,diverProfile:diver?.profile||null,equipment:Array.isArray(diver?.equipment)?diver.equipment:[],diverProfileAvailable:diverResult.ok};document.dispatchEvent(new CustomEvent('hydroland:profile-data-ready'));if(!diverResult.ok&&diverResult.error?.message!=='AUTH_REQUIRED')toast('تعذر تحميل بيانات الغواص والمعدات');bindCredentialActions();} - catch(error){if(version===loadVersion&&sessionVersion===auth().getSessionVersion()&&error.message!=='AUTH_REQUIRED'&&error.message!=='AUTH_CANCELLED')toast('تعذر تحميل بيانات الحساب من الخادم');} + const currentSession=()=>sessionVersion===auth()?.getSessionVersion()&&auth()?.isAuthenticated?.(); + // A superseded caller must wait for the replacement to publish its result. + // Otherwise a retry can inspect stale availability while a background load is pending. + const replacement=()=>{if(!currentSession())return;for(let next=version+1;next<=loadVersion;next++){const entry=loadPromises.get(next);if(entry?.sessionVersion===sessionVersion)return entry.promise}}; + const promise=(async()=>{ + try{ + const [profile,credentials,diverResult]=await Promise.all([ + request('/me'),request('/credentials'), + request('/me/diver-profile').then(data=>({ok:true,data})).catch(error=>({ok:false,error})) + ]); + if(!currentSession())return; + if(version!==loadVersion)return replacement(); + const normalizedProfile=normalizeProfile(profile); + renderProfile(normalizedProfile,Array.isArray(credentials)?credentials:[]); + const diver=diverResult.ok?diverResult.data:null; + window.HydrolandProfileData={profile:normalizedProfile,credentials,diverProfile:diver?.profile||null,equipment:Array.isArray(diver?.equipment)?diver.equipment:[],diverProfileAvailable:diverResult.ok}; + document.dispatchEvent(new CustomEvent('hydroland:profile-data-ready')); + if(!diverResult.ok&&diverResult.error?.message!=='AUTH_REQUIRED')toast('تعذر تحميل بيانات الغواص والمعدات'); + bindCredentialActions(); + return window.HydrolandProfileData; + }catch(error){ + if(!currentSession())return; + if(version!==loadVersion)return replacement(); + if(error.message!=='AUTH_REQUIRED'&&error.message!=='AUTH_CANCELLED')toast('تعذر تحميل بيانات الحساب من الخادم'); + } + })(); + const entry={version,sessionVersion,promise};latestLoad=entry;loadPromises.set(version,entry); + promise.finally(()=>{if(loadPromises.get(version)?.promise===promise)loadPromises.delete(version)}); + return promise; } async function saveProfile(input){try{const updated=await request('/me',{method:'PATCH',body:JSON.stringify(input)});toast('تم حفظ بيانات الحساب');await load();return updated}catch(error){toast(error.message==='AUTH_REQUIRED'?'سجل الدخول أولًا':'تعذر حفظ بيانات الحساب');throw error}} const ensureCredentialDialog=()=>{let dialog=document.getElementById('hl-credential-editor');if(dialog)return dialog;dialog=document.createElement('dialog');dialog.id='hl-credential-editor';dialog.className='hl-profile-editor';dialog.innerHTML=`
CREDENTIAL · الشهادات

إضافة شهادة

`;const form=dialog.querySelector('form'),close=()=>dialog.close();dialog.querySelector('[data-credential-close]').addEventListener('click',close);dialog.querySelector('[data-credential-cancel]').addEventListener('click',close);form.addEventListener('submit',async event=>{event.preventDefault();const data=new FormData(form),status=form.querySelector('.hl-profile-editor-status'),submit=form.querySelector('[type="submit"]'),body={issuer:String(data.get('issuer')||'').trim(),title:String(data.get('title')||'').trim(),credentialNumber:String(data.get('credentialNumber')||'').trim()||undefined,issuedAt:String(data.get('issuedAt')||'')||undefined,expiresAt:String(data.get('expiresAt')||'')||undefined};if(!body.issuer||!body.title){status.textContent='الجهة المانحة واسم الشهادة مطلوبان';return}submit.disabled=true;status.textContent='جارٍ حفظ الشهادة...';try{await request('/credentials',{method:'POST',body:JSON.stringify(body)});form.reset();close();toast('تمت إضافة الشهادة');await load()}catch(error){status.textContent=error.message==='AUTH_REQUIRED'?'سجل الدخول أولًا':'تعذر حفظ الشهادة'}finally{submit.disabled=false}});document.body.appendChild(dialog);return dialog}; @@ -100,6 +127,6 @@ document.addEventListener('click',event=>{const btn=event.target.closest?.('[data-hl-action="diver-equipment"]');if(!btn)return;event.preventDefault();openEquipmentEditor()}); document.addEventListener('click',event=>{const btn=event.target.closest?.('[data-hl-action="diver-profile"]');if(!btn)return;event.preventDefault();if(!auth()?.isAuthenticated?.()){toast('سجل الدخول أولًا');return}openDiverEditor()}); document.addEventListener('click',async event=>{const btn=event.target.closest?.('[data-hl-action="settings"]');if(!btn)return;event.preventDefault();if(!auth()?.isAuthenticated?.()){toast('سجل الدخول أولًا لفتح بيانات الحساب');return}let current=window.HydrolandProfileData?.profile;if(!current){toast('جارٍ تحميل بيانات الحساب...');await load();current=window.HydrolandProfileData?.profile;if(!current)return}openProfileEditor(current)}); - document.addEventListener('hydroland:session-cleared',()=>{loadVersion++;window.HydrolandProfileData=undefined;renderProfile({},[])}); + document.addEventListener('hydroland:session-cleared',()=>{loadVersion++;latestLoad=null;loadPromises.clear();window.HydrolandProfileData=undefined;renderProfile({},[])}); document.addEventListener('hydroland:auth-changed',()=>{if(!auth()?.isAuthenticated?.())window.HydrolandProfileData=undefined;load()});setTimeout(load,500);window.HydrolandProfile={load,saveProfile}; })(); diff --git a/apps/web/src/hydroland-role-dashboards.js b/apps/web/src/hydroland-role-dashboards.js index 3b63212af..87b2aba00 100644 --- a/apps/web/src/hydroland-role-dashboards.js +++ b/apps/web/src/hydroland-role-dashboards.js @@ -14,8 +14,8 @@ }, center:{ k:'DIVE CENTER · مركز الغوص',t:'تشغيل مركز الغوص',s:'تشغيل الحجوزات والرحلات والمعدات ومحترفي الغوص والتقارير والملف التجاري.',icon:'◉', - m:[['—','حجوزات جديدة'],['—','رحلات اليوم'],['—','جاهزية المعدات'],['—','مراجعات سلامة']], - nav:['الرئيسية','إدارة الحجوزات','الرحلات','المعدات والمخزون','محترفي الغوص','العملاء','المستندات والتراخيص','التقارير','الملف التجاري'], + m:[['—','حجوزات جديدة'],['—','رحلات اليوم'],['—','أعضاء نشطون'],['—','إجمالي الرحلات']], + nav:['الرئيسية','إدارة الحجوزات','الرحلات','المعدات والمخزون','محترفي الغوص','العملاء','المستندات والتراخيص','السلامة','التقارير','الملف التجاري'], a:[['إدارة الحجوزات','trips','▣'],['إدارة الرحلات','trips','⌖'],['المخزون والمعدات','store','◫'],['الطاقم والمدربين','trips','♟'],['المستندات والتراخيص','documents','◇'],['تقارير السلامة','safety','⬡'],['التقارير المالية','finance','◈']], q:[['الحجوزات المؤكدة','من النظام'],['رحلات اليوم','من النظام'],['فحوص المعدات','من النظام']], spotlight:['تشغيل اليوم','الحجوزات والرحلات وحالة الجاهزية تظهر من بيانات التشغيل المتصلة.'], @@ -51,13 +51,13 @@ }; function clearDashboard(){document.querySelector('.hl-role-dashboard')?.remove()} - const syncDashboardIdentity=()=>{const d=document.querySelector('.hl-role-dashboard');if(!d)return;const person=window.HydrolandProfileData?.profile?.person||{},name=[person.firstName,person.lastName].filter(Boolean).join(' ').trim();d.querySelectorAll('[data-portal-name],[data-center-name]').forEach(node=>{const value=name||'حساب HYDROLAND';if(node.textContent!==value)node.textContent=value});d.querySelectorAll('[data-portal-avatar],[data-center-avatar]').forEach(node=>{const value=(person.firstName||'H').trim().slice(0,1).toUpperCase();if(node.textContent!==value)node.textContent=value})}; + const syncDashboardIdentity=()=>{const d=document.querySelector('.hl-role-dashboard');if(!d)return;const person=window.HydrolandProfileData?.profile?.person||{},name=[person.firstName,person.lastName].filter(Boolean).join(' ').trim();d.querySelectorAll('[data-portal-name],[data-center-name]').forEach(node=>{if(node.hasAttribute('data-center-name')&&d.dataset.centerScopeLoaded==='1')return;const value=name||'حساب HYDROLAND';if(node.textContent!==value)node.textContent=value});d.querySelectorAll('[data-portal-avatar],[data-center-avatar]').forEach(node=>{const value=(person.firstName||'H').trim().slice(0,1).toUpperCase();if(node.textContent!==value)node.textContent=value})}; const loadCenterSummary=async d=>{ const auth=window.HydrolandAuth;if(!d||d.dataset.role!=='center'||!auth?.isAuthenticated?.())return; const session=auth.getSessionVersion?.(); try{const response=await auth.authorizedFetch('/center/me/overview'),data=await response.json().catch(()=>null);if(!response.ok)throw new Error(data?.message||'CENTER_UNAVAILABLE');if(!d.isConnected||session!==auth.getSessionVersion?.())return; const tiles=d.querySelectorAll('.hl-role-tile b'),values=[data?.metrics?.newBookings??0,data?.metrics?.tripsToday??0,data?.metrics?.activeMembers??0,data?.metrics?.totalTrips??0];values.forEach((value,index)=>{if(tiles[index])tiles[index].textContent=String(value)}); - const name=data?.center?.displayName||data?.center?.documentBrandNameAr||'مركز الغوص';d.querySelectorAll('[data-portal-name],[data-center-name]').forEach(node=>node.textContent=name);d.dataset.centerScopeLoaded='1'; + const name=data?.center?.displayName||data?.center?.documentBrandNameAr||'مركز الغوص';d.querySelectorAll('[data-center-name]').forEach(node=>node.textContent=name);d.dataset.centerScopeLoaded='1'; }catch{if(d.isConnected){d.dataset.centerScopeLoaded='0';d.querySelectorAll('.hl-role-tile b').forEach(node=>node.textContent='—')}} }; const loadInstructorSummary=async d=>{ @@ -83,22 +83,23 @@ }; const ensureWeatherAdmin=()=>{if(document.querySelector('script[data-hl-weather-admin]'))return;const script=document.createElement('script');script.src='./hydroland-weather-admin.js';script.dataset.hlWeatherAdmin='1';document.body.appendChild(script)}; const denyRoleAction=()=>{clearDashboard();const exit=document.getElementById('exit-role');if(exit)exit.click();else window.HydrolandPortalAccess?.clearProtectedPortal?.();return false}; - const authorizeRoleAction=async role=>{const auth=window.HydrolandAuth,access=window.HydrolandPortalAccess;if(!auth?.isAuthenticated?.()||!access?.roleAllowed?.(role))return denyRoleAction();if(typeof access.refreshPortalAccess!=='function')return denyRoleAction();await access.refreshPortalAccess();if(window.HydrolandPortalFreshness?.enforce?.()===false)return false;if(!auth.isAuthenticated()||!access.roleAllowed(role))return denyRoleAction();return true}; + const authorizeRoleAction=async role=>{const auth=window.HydrolandAuth,access=window.HydrolandPortalAccess;if(!auth?.isAuthenticated?.()||typeof access?.authorizeRole!=='function')return denyRoleAction();const allowed=await access.authorizeRole(role,{sessionVersion:auth.getSessionVersion?.()});if(!allowed)return denyRoleAction();return true}; const routeSelectors={finance:'.hl-finance',admin:'.hl-admin',documents:'#hl-documents','marine-documents':'#hl-marine-documents',inventory:'.hl-inventory',training:'.hl-training',crew:'.hl-crew-assignments',organizations:'.hl-organizations',incidents:'#hl-safety-incidents','trip-admin':'.hl-trip-admin','store-admin':'.hl-store-admin',weather:'.hl-weather-admin',procurement:'.hl-procurement',logistics:'.hl-logistics','theme-admin':'.hl-theme-admin'}; const targetFor=id=>routeSelectors[id]?document.querySelector(routeSelectors[id]):document.getElementById(id); - const openTarget=(id,role,label)=>{if(role==='center'&&label==='السلامة'){window.HydrolandCenterSafety?.open?.();return}if(role==='center'&&label==='المستندات والتراخيص'){window.HydrolandCenterDocuments?.open?.();return}if(role==='center'&&label==='المعدات والمخزون'){window.HydrolandCenterEquipment?.open?.();return}if(role==='center'&&label==='المعدات والمخزون'){window.HydrolandCenterEquipment?.open?.();return}if(role==='center'&&label==='العملاء'){window.HydrolandCenterCustomers?.open?.();return}if(role==='center'&&['إدارة الحجوزات','إدارة الرحلات'].includes(label)){window.HydrolandCenterOperations?.open?.();return}if(role==='center'&&label==='محترفو الغوص'){window.HydrolandCenterTeam?.open?.('professionals');return}const actualId=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id)?'training':id;const target=targetFor(actualId);if(actualId==='documents')window.HydrolandDocuments?.open?.();if(actualId==='marine-documents')window.HydrolandMarineDocuments?.open?.(role);if(actualId==='incidents')window.HydrolandSafetyIncidents?.open?.(role);if(actualId==='training')window.HydrolandTraining?.reload?.(id==='training-schedule'?'instructor-schedule':id==='training-skills'?'instructor-skills':id==='training-certificates'?'instructor-certificates':id==='training-earnings'?'instructor-earnings':role==='instructor'?'instructor':undefined);if(actualId==='hl-marine-readiness')window.HydrolandMarineReadiness?.open?.(role);window.HydrolandWorkspaceUI?.show?.(target)}; - const setTargetAvailability=(node,id,label)=>{if(!node.hasAttribute('data-hl-original-aria-label'))node.dataset.hlOriginalAriaLabel=node.getAttribute('aria-label')||'';const resolvedId=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id)?'training':id,target=targetFor(resolvedId),available=Boolean(target&&!target.classList.contains('hl-workspace-hidden')&&(!target.hidden||['documents','marine-documents','incidents','hl-marine-readiness'].includes(id)));node.disabled=!available;if(available){node.removeAttribute('aria-disabled');node.title='';node.removeAttribute('data-hl-unavailable');if(node.dataset.hlOriginalAriaLabel)node.setAttribute('aria-label',node.dataset.hlOriginalAriaLabel);else node.removeAttribute('aria-label')}else{node.setAttribute('aria-disabled','true');node.title='الخدمة جارٍ تحميلها أو غير متاحة';node.dataset.hlUnavailable='1';node.setAttribute('aria-label',label+' · الخدمة غير متاحة حاليًا')}return available}; - const connectControl=(node,role,id,label)=>{node.dataset.hlRoute=id;node.dataset.hlRole=role;if(!node.dataset.hlConnected){node.dataset.hlConnected='1';node.addEventListener('click',async event=>{event.preventDefault();event.stopImmediatePropagation();const route=node.dataset.hlRoute,activeRole=node.dataset.hlRole;if(!(await authorizeRoleAction(activeRole)))return;const target=targetFor(['training-schedule','training-skills','training-certificates','training-earnings'].includes(route)?'training':route);if(!target){setTargetAvailability(node,route,label);return}openTarget(route,activeRole,label);if(route==='training'&&activeRole==='instructor')return;target.scrollIntoView({behavior:'smooth',block:'start'});history.replaceState(null,'','#'+(route==='documents'?'hl-documents':route==='marine-documents'?'hl-marine-documents':route))})}return setTargetAvailability(node,id,label)}; + const openTarget=(id,role,label)=>{if(role==='center'&&id==='center-safety'){window.HydrolandCenterSafety?.open?.();return}if(role==='center'&&id==='center-documents'){window.HydrolandCenterDocuments?.open?.();return}if(role==='center'&&id==='center-equipment'){window.HydrolandCenterEquipment?.open?.();return}if(role==='center'&&id==='center-customers'){window.HydrolandCenterCustomers?.open?.();return}if(role==='center'&&id==='center-operations'){window.HydrolandCenterOperations?.open?.();return}if(role==='center'&&id==='center-team'){window.HydrolandCenterTeam?.open?.(['محترفو الغوص','محترفي الغوص'].includes(label)?'professionals':'team');return}const actualId=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id)?'training':id;if(id==='professional-profile'){document.getElementById('profile-dialog')?.showModal();return}const target=targetFor(actualId);if(actualId==='documents')window.HydrolandDocuments?.open?.();if(actualId==='marine-documents')window.HydrolandMarineDocuments?.open?.(role);if(actualId==='incidents')window.HydrolandSafetyIncidents?.open?.(role);if(actualId==='training'){const trainingTarget=document.querySelector('.hl-training');window.HydrolandWorkspaceUI?.show?.(trainingTarget);const mode=id==='training-schedule'?'instructor-schedule':id==='training-skills'?'instructor-skills':id==='training-certificates'?'instructor-certificates':id==='training-earnings'?'instructor-earnings':role==='instructor'?'instructor':undefined;Promise.resolve(window.HydrolandTraining?.reload?.(mode)).finally(()=>window.HydrolandWorkspaceUI?.show?.(document.querySelector('.hl-training')));return}if(actualId==='hl-marine-readiness')window.HydrolandMarineReadiness?.open?.(role);window.HydrolandWorkspaceUI?.show?.(target)}; + const setTargetAvailability=(node,id,label)=>{if(!node.hasAttribute('data-hl-original-aria-label'))node.dataset.hlOriginalAriaLabel=node.getAttribute('aria-label')||'';const professionalTraining=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id),resolvedId=professionalTraining?'training':id,target=professionalTraining?document.querySelector('.hl-training'):targetFor(resolvedId),available=professionalTraining?Boolean(target&&window.HydrolandPortalAccess?.roleAllowed?.('instructor')):id==='professional-profile'?Boolean(document.getElementById('profile-dialog')):id==='center-safety'?Boolean(window.HydrolandCenterSafety?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):id==='center-documents'?Boolean(window.HydrolandCenterDocuments?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):id==='center-equipment'?Boolean(window.HydrolandCenterEquipment?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):id==='center-customers'?Boolean(window.HydrolandCenterCustomers?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):id==='center-operations'?Boolean(window.HydrolandCenterOperations?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):id==='center-team'?Boolean(window.HydrolandCenterTeam?.open&&window.HydrolandPortalAccess?.roleAllowed?.('center')):Boolean(target&&!target.classList.contains('hl-workspace-hidden')&&(!target.hidden||['documents','marine-documents','incidents','hl-marine-readiness'].includes(id)));node.disabled=!available;if(available){node.removeAttribute('aria-disabled');node.title='';node.removeAttribute('data-hl-unavailable');if(node.dataset.hlOriginalAriaLabel)node.setAttribute('aria-label',node.dataset.hlOriginalAriaLabel);else node.removeAttribute('aria-label')}else{node.setAttribute('aria-disabled','true');node.title='الخدمة جارٍ تحميلها أو غير متاحة';node.dataset.hlUnavailable='1';node.setAttribute('aria-label',label+' · الخدمة غير متاحة حاليًا')}return available}; + const connectControl=(node,role,id,label)=>{node.dataset.hlRoute=id;node.dataset.hlRole=role;if(!node.dataset.hlConnected){node.dataset.hlConnected='1';node.addEventListener('click',async event=>{event.preventDefault();event.stopImmediatePropagation();const route=node.dataset.hlRoute,activeRole=node.dataset.hlRole;if(!(await authorizeRoleAction(activeRole)))return;if(activeRole==='center'&&['center-safety','center-documents','center-equipment','center-customers','center-operations','center-team'].includes(route)){openTarget(route,activeRole,label);return}const controllerRoute=route==='professional-profile'||['training-schedule','training-skills','training-certificates','training-earnings'].includes(route),target=controllerRoute?null:targetFor(route);if(!controllerRoute&&!target){setTargetAvailability(node,route,label);return}openTarget(route,activeRole,label);if(controllerRoute||(route==='training'&&activeRole==='instructor'))return;target.scrollIntoView({behavior:'smooth',block:'start'});history.replaceState(null,'','#'+(route==='documents'?'hl-documents':route==='marine-documents'?'hl-marine-documents':route))})}return setTargetAvailability(node,id,label)}; const portalNavTargets={'إدارة الدورات':'training','الطلاب':'training','الجدول الزمني':'training','التقييمات':'community','الشهادات':'community','الإيرادات':'finance','إدارة الحجوزات':'trips','الرحلات':'trips','المعدات والمخزون':'store','محترفي الغوص':'training','العملاء':'community','التقارير':'admin','الملف التجاري':'community','إدارة القوارب':'trips','الرحلات والحجوزات':'trips','الطاقم':'trips','الجدولة والتقويم':'trips','الصيانة والمعدات':'store','السلامة واللوائح':'safety','الطقس وحالة البحر':'marine-intelligence','المالية والفواتير':'finance','التسويق والعروض':'community','طلبات الخدمات':'trips','الحجوزات والعقود':'community','المشاركون':'community','المستندات والتصاريح':'documents','السلامة والامتثال':'safety','الفواتير':'finance','المستخدمون':'admin','الموافقات والطلبات':'admin','مراكز الغوص':'admin','الوسائط البحرية':'admin','الشركات والجهات الحكومية':'admin','المعدات والمستودعات':'store','التقارير والإحصائيات':'admin','الذكاء الاصطناعي':'admin','إدارة المحتوى':'community','الإشعارات والرسائل':'community','الإعدادات':'admin'}; - const portalNavTarget=(role,label)=>{if(label==='المستندات والتراخيص')return role==='boat'?'marine-documents':'documents';if(label==='الصيانة والمعدات'&&role==='boat')return 'hl-marine-readiness';if(role==='admin'&&label==='الوسائط البحرية')return 'marine-documents';if(['المعدات والمخزون','المعدات والمستودعات'].includes(label))return 'inventory';if(label==='الطاقم')return 'crew';if(label==='إدارة القوارب')return 'marine-documents';if(label==='الطقس وحالة البحر')return role==='admin'?'weather':'marine-intelligence';if(label==='الذكاء الاصطناعي'||label==='التسويق والعروض')return null;return portalNavTargets[label]}; + const portalNavTarget=(role,label)=>{if(role==='instructor'&&label==='إدارة الدورات')return 'training';if(role==='instructor'&&label==='الطلاب')return 'training';if(role==='instructor'&&label==='الجدول الزمني')return 'training-schedule';if(role==='instructor'&&label==='التقييمات')return 'training-skills';if(role==='instructor'&&label==='الشهادات')return 'training-certificates';if(role==='instructor'&&label==='الإيرادات')return 'training-earnings';if(role==='center'&&label==='السلامة')return 'center-safety';if(role==='center'&&label==='المستندات والتراخيص')return 'center-documents';if(role==='center'&&label==='المعدات والمخزون')return 'center-equipment';if(role==='center'&&label==='العملاء')return 'center-customers';if(role==='center'&&['إدارة الحجوزات','الرحلات'].includes(label))return 'center-operations';if(role==='center'&&label==='محترفي الغوص')return 'center-team';if(label==='المستندات والتراخيص')return role==='boat'?'marine-documents':'documents';if(label==='الصيانة والمعدات'&&role==='boat')return 'hl-marine-readiness';if(role==='admin'&&label==='الوسائط البحرية')return 'marine-documents';if(['المعدات والمخزون','المعدات والمستودعات'].includes(label))return 'inventory';if(label==='الطاقم')return 'crew';if(label==='إدارة القوارب')return 'marine-documents';if(label==='الطقس وحالة البحر')return role==='admin'?'weather':'marine-intelligence';if(label==='الذكاء الاصطناعي'||label==='التسويق والعروض')return null;return portalNavTargets[label]}; const reconnectDashboard=()=>{const d=document.querySelector('.hl-role-dashboard');if(!d)return;const role=d.dataset.role;d.querySelectorAll('[data-hl-route]').forEach(node=>connectControl(node,role,node.dataset.hlRoute,node.dataset.actionLabel||node.dataset.secondaryLabel||node.textContent.trim()));d.querySelectorAll('.hl-portal-nav-item').forEach((node,index)=>{if(index===0)return;const label=node.dataset.portalLabel,id=portalNavTarget(role,label);if(!id){node.disabled=true;node.title='قيد الربط بالخدمة';node.setAttribute('aria-disabled','true');return}node.dataset.hlNavRoute=id;setTargetAvailability(node,id,label)})}; let reconnectQueued=false;new MutationObserver(()=>{if(reconnectQueued)return;reconnectQueued=true;queueMicrotask(()=>{reconnectQueued=false;reconnectDashboard()})}).observe(document.body,{childList:true,subtree:true}); const iconFor=label=>/الرئيسية|القيادة/.test(label)?'home':/سلامة|امتثال|حوادث/.test(label)?'shield':/مستند|وثائق|عقود|تراخيص|تصاريح|توقيع/.test(label)?'file':/مالية|إيراد|فواتير/.test(label)?'chart':/معدات|مخزون|مستودع|غيار/.test(label)?'tanks':/دورات|تدريب|محترفي|المواد/.test(label)?'learn':/شهادات|اعتماد|موافقة/.test(label)?'certificate':/طقس|البحر/.test(label)?'cloud':/قارب|قوارب|رحل|أسطول/.test(label)?'boat':/جدول|جلسات|تقويم/.test(label)?'calendar':/رسائل|إشعارات/.test(label)?'mail':/إعداد|صيانة|فنية/.test(label)?'settings':/مستخدم|طلاب|طاقم|عملاء|مشارك|مركز|جهات/.test(label)?'people':/ذكاء/.test(label)?'bot':/تقارير|تقييم/.test(label)?'chart':'compass'; const actionRoute=(role,label,fallback)=>{ + if(role==='center'&&['السلامة','تقارير السلامة'].includes(label))return 'center-safety';if(role==='center'&&label==='المستندات والتراخيص')return 'center-documents';if(role==='center'&&['المعدات والمخزون','المخزون والمعدات','عرض المخزون'].includes(label))return 'center-equipment';if(role==='center'&&label==='العملاء')return 'center-customers';if(role==='center'&&['إدارة الحجوزات','إدارة الرحلات','عرض الجدول','عرض قائمة التشغيل'].includes(label))return 'center-operations';if(role==='center'&&['الطاقم والمدربين','محترفو الغوص','محترفي الغوص'].includes(label))return 'center-team'; if(label==='مركز الحوادث')return 'incidents';if(label==='المخزون والمعدات'||label==='المعدات والمستودعات')return 'inventory';if(label==='إدارة المحتوى')return 'theme-admin';if(label==='إعدادات المنصة')return 'admin'; if(label==='إدارة الرحلات'||label==='إنشاء رحلة'||(label==='الرحلات والحجوزات'&&role==='admin'))return 'trip-admin';if(label==='الطاقم والمدربين'||label==='إدارة الطاقم')return 'crew';if(label==='إدارة القوارب')return 'marine-documents';if(label==='سجل الصيانة')return 'hl-marine-readiness';if(label==='حالة البحر والطقس'||label==='الطقس وحالة البحر')return role==='admin'?'weather':'marine-intelligence'; - if(role==='instructor'&&label==='جدول التدريب')return 'training-schedule';if(role==='instructor'&&label==='تقييم المهارات')return 'training-skills';if(role==='instructor'&&label==='إصدار الشهادات')return 'training-certificates';if(role==='instructor'&&label==='الإيرادات')return 'training-earnings';if(label==='التسويق والعروض')return null;return fallback; + if(role==='instructor'&&label==='جدول التدريب')return 'training-schedule';if(role==='instructor'&&label==='تقييم المهارات')return 'training-skills';if(role==='instructor'&&label==='إصدار الشهادات')return 'training-certificates';if(role==='instructor'&&label==='الإيرادات')return 'training-earnings';if(role==='instructor'&&label==='الملف المهني')return 'professional-profile';if(label==='التسويق والعروض')return null;return fallback; }; const highlights=()=>`
${[['shield','سلامة أولًا'],['water','تجارب مميزة'],['settings','تشغيل احترافي'],['boat','رحلات بحرية موثقة']].map(([art,text])=>`${icon(art)}${text}`).join('')}
`; const panel=(title,art,body,cls='')=>`

${icon(art)}${title}

${body}
`; @@ -113,7 +114,7 @@ if(role==='organization')return `
${panel('الرسائل والإشعارات','mail',`

تابع مراسلات الجهة وفريقك

`)}${safetyPanel()}${documentsPanel()}${panel('مسار الموافقات','certificate',`
  1. إنشاء الطلب
  2. المراجعة والتوقيع
  3. الاعتماد والتوثيق
${action('عرض مسار الموافقات','documents')}`)}
`; if(role==='center')return `
${panel('حالة المعدات والمخزون','tanks',`${rows([['أسطوانات الغوص','من النظام'],['مستلزمات السلامة','من النظام'],['معدات الغوص','من النظام']])}${action('عرض المخزون','inventory')}`,'hl-center-stock')}${panel('جدول الرحلات والتدريب','calendar',`${rows([['الرحلات القادمة','من الجدول']])}${action('عرض الجدول','trips')}`,'hl-center-schedule')}
${safetyPanel()}${queuePanel(c)}
`; if(role==='boat')return `
${panel('الخدمات الفنية وقطع الغيار','wrench',`${rows([['الصيانة والمعدات','سجل المعدات'],['المشتريات','متابعة الطلبات']])}${action('الخدمات الفنية','inventory',true)}${action('قطع الغيار','procurement',true)}`)}${safetyPanel()}${queuePanel(c)}
`; - return `
${panel('التدريب والتقييم','learn',`${rows([['المواد التدريبية','إدارة الدورات'],['الحضور والمهارات','سجل التدريب']])}${action('المواد التدريبية','training',true)}`)}${panel('الشهادات والملف المهني','certificate',`

شهاداتك واعتماداتك وجداول التكليف

${action('الملف المهني','community',true)}`)}${queuePanel(c)}
`; + return `
${panel('التدريب والتقييم','learn',`${rows([['المواد التدريبية','إدارة الدورات'],['الحضور والمهارات','سجل التدريب']])}${action('المواد التدريبية','training',true)}`)}${panel('الشهادات والملف المهني','certificate',`

شهاداتك واعتماداتك وجداول التكليف

${action('الملف المهني','professional-profile',true)}`)}${queuePanel(c)}
`; }; function render(role){ clearDashboard(); @@ -156,8 +157,8 @@ if(freshness?.openRoleSwitcher)await freshness.openRoleSwitcher(); else document.getElementById('role-dialog')?.showModal(); }); - d.querySelectorAll('[data-route]').forEach(node=>{const id=node.dataset.route,label=node.dataset.actionLabel||node.textContent.trim();connectControl(node,role,id,label)}); - d.querySelectorAll('.hl-portal-nav-item').forEach((node,index)=>node.addEventListener('click',async event=>{event.preventDefault();event.stopImmediatePropagation();if(index===0){window.HydrolandWorkspaceUI?.show?.(d);d.classList.remove('hl-portal-nav-open');d.scrollIntoView({behavior:'smooth',block:'start'});return}const id=node.dataset.hlNavRoute;if(!id||!(await authorizeRoleAction(role)))return;const target=targetFor(id);if(!target){setTargetAvailability(node,id,node.dataset.portalLabel);return}d.querySelectorAll('.hl-portal-nav-item').forEach(x=>x.classList.remove('active'));node.classList.add('active');openTarget(id,role);d.classList.remove('hl-portal-nav-open');target.scrollIntoView({behavior:'smooth',block:'start'});history.replaceState(null,'','#'+(id==='documents'?'hl-documents':id==='marine-documents'?'hl-marine-documents':id))})); + d.querySelectorAll('[data-route]').forEach(node=>{const label=node.dataset.actionLabel||node.textContent.trim(),id=actionRoute(role,label,node.dataset.route);if(!id){node.disabled=true;node.setAttribute('aria-disabled','true');return}connectControl(node,role,id,label)}); + d.querySelectorAll('.hl-portal-nav-item').forEach((node,index)=>node.addEventListener('click',async event=>{event.preventDefault();event.stopImmediatePropagation();if(index===0){window.HydrolandWorkspaceUI?.show?.(d);d.classList.remove('hl-portal-nav-open');d.scrollIntoView({behavior:'smooth',block:'start'});return}const id=node.dataset.hlNavRoute;if(!id||!(await authorizeRoleAction(role)))return;if(role==='center'&&['center-safety','center-documents','center-equipment','center-customers','center-operations','center-team'].includes(id)){openTarget(id,role,node.dataset.portalLabel);d.classList.remove('hl-portal-nav-open');return}const resolvedId=['training-schedule','training-skills','training-certificates','training-earnings'].includes(id)?'training':id,target=targetFor(resolvedId);if(!target){setTargetAvailability(node,id,node.dataset.portalLabel);return}d.querySelectorAll('.hl-portal-nav-item').forEach(x=>x.classList.remove('active'));node.classList.add('active');openTarget(id,role);d.classList.remove('hl-portal-nav-open');target.scrollIntoView({behavior:'smooth',block:'start'});history.replaceState(null,'','#'+(id==='documents'?'hl-documents':id==='marine-documents'?'hl-marine-documents':id))})); reconnectDashboard(); } diff --git a/apps/web/src/hydroland-role-task-routing.js b/apps/web/src/hydroland-role-task-routing.js index 604875631..5b0bfcca6 100644 --- a/apps/web/src/hydroland-role-task-routing.js +++ b/apps/web/src/hydroland-role-task-routing.js @@ -20,11 +20,8 @@ const authorizeCurrentRole=async()=>{ const auth=window.HydrolandAuth,access=window.HydrolandPortalAccess; const role=access?.getCurrentRole?.()||'diver'; - if(role==='diver'||!auth?.isAuthenticated?.()||!access?.roleAllowed?.(role))return false; - if(typeof access.refreshPortalAccess!=='function')return false; - await access.refreshPortalAccess(); - if(window.HydrolandPortalFreshness?.enforce?.()===false)return false; - return Boolean(auth.isAuthenticated()&&access.roleAllowed(role)); + if(role==='diver'||!auth?.isAuthenticated?.()||typeof access?.authorizeRole!=='function')return false; + return access.authorizeRole(role,{sessionVersion:auth.getSessionVersion?.()}); }; const navigate=config=>{ if(config.action){config.action();return true} @@ -59,7 +56,4 @@ if(root)new MutationObserver(()=>queueMicrotask(connect)).observe(root,{childList:true}); connect(); window.HydrolandRoleTasks={refresh:connect}; - const freshnessScript=document.createElement('script'); - freshnessScript.src='./hydroland-portal-access-freshness.js'; - document.body.appendChild(freshnessScript); })(); \ No newline at end of file diff --git a/apps/web/src/hydroland-workspace-ui.css b/apps/web/src/hydroland-workspace-ui.css index ed84dfa57..a9d4c46a2 100644 --- a/apps/web/src/hydroland-workspace-ui.css +++ b/apps/web/src/hydroland-workspace-ui.css @@ -54,3 +54,9 @@ body.hl-visitor-mode #role-switch{display:none!important} .hl-diver-workspace .site-footer{display:none} @media(max-width:1200px){.hl-managed-workspace #main>.hl-workspace-selected{margin-right:228px;max-width:calc(100% - 252px)}} @media(max-width:760px){.hl-managed-workspace{padding-bottom:0}.hl-managed-workspace #main>.hl-workspace-selected{margin:12px;max-width:calc(100% - 24px)}.hl-support-guest-gate{align-items:stretch;flex-direction:column}} + +/* Managed professional training owns the actual direct child of #main that contains the training service. */ +.hl-managed-workspace[data-hl-workspace-service="hl-training-service"] #main>.hl-managed-service-root{display:block!important;margin:16px 256px 24px 24px;max-width:calc(100% - 280px)} +.hl-managed-workspace[data-hl-workspace-service="hl-training-service"] .hl-training.hl-workspace-selected{display:block!important} +@media(max-width:1200px){.hl-managed-workspace[data-hl-workspace-service="hl-training-service"] #main>.hl-managed-service-root{margin-right:228px;max-width:calc(100% - 252px)}} +@media(max-width:760px){.hl-managed-workspace[data-hl-workspace-service="hl-training-service"] #main>.hl-managed-service-root{margin:12px;max-width:calc(100% - 24px)}} diff --git a/apps/web/src/hydroland-workspace-ui.js b/apps/web/src/hydroland-workspace-ui.js index a386256e5..ec93fee76 100644 --- a/apps/web/src/hydroland-workspace-ui.js +++ b/apps/web/src/hydroland-workspace-ui.js @@ -42,6 +42,7 @@ document.querySelectorAll(selector).forEach(node=>node.classList.toggle('hl-workspace-hidden',!isAuthed||!roles.includes(role))); } syncNavigation(role); + if(selected?.isConnected&&isAuthed){selected.classList.add('hl-workspace-selected');if(document.body.dataset.hlWorkspaceService==='hl-training-service'){const training=selected.matches('.hl-training')?selected:selected.querySelector?.('.hl-training');training?.classList.add('hl-workspace-selected');selected.classList.add('hl-managed-service-root')}} const home=document.querySelector('#navigation > a[href="#home"],#navigation > a[href="#hl-diver-dashboard"]'); if(home){ const label=home.querySelector('span:last-child'); @@ -63,25 +64,28 @@ fragment.innerHTML=[['home','الرئيسية','#hl-diver-dashboard'],['boat','الرحلات والحجوزات','#trips'],['water','سجل الغوص','logbook'],['tanks','المعدات','diver-equipment-list'],['certificate','الشهادات','certs'],['bag','المتجر والتأجير','#store'],['people','المجتمع','#community'],['pin','المواقع','#marine-intelligence'],['learn','التدريب','#training'],['mail','الرسائل','messages'],['help','مركز المساعدة','#hl-support'],['settings','الإعدادات','settings']].map(([art,label,target])=>target.startsWith('#')?`${icon(art)}${label}`:``).join('');nav.replaceChildren(...fragment.children,switcher); }; const main=document.getElementById('main'); - const serviceGroups={'hl-marine-documents':['#hl-marine-documents','#hl-marine-readiness'],training:['#training','.hl-training'],community:['#community','.hl-community','.hl-support','.hl-members'],safety:['#safety','.hl-safety-center','.hl-safety-review','#hl-safety-incidents','#hl-marine-readiness']}; + const serviceGroups={'hl-marine-documents':['#hl-marine-documents','#hl-marine-readiness'],'hl-training-service':['.hl-training'],training:['#training','.hl-training'],community:['#community','.hl-community','.hl-support','.hl-members'],safety:['#safety','.hl-safety-center','.hl-safety-review','#hl-safety-incidents','#hl-marine-readiness']}; let selected=null; const show=target=>{ if(!auth()||!target)return false; + setWorkspace(); let node=typeof target==='string'?document.getElementById(target):target;if(!node)return false; const role=currentRole(),home=node.matches('.hl-role-dashboard,#hl-diver-dashboard,#home'); main.querySelectorAll('.hl-workspace-selected').forEach(item=>item.classList.remove('hl-workspace-selected')); const heading=document.querySelector('.hl-service-heading'); - if(home){selected=null;delete document.body.dataset.hlWorkspaceView;if(heading)heading.hidden=true;history.replaceState(null,'','#'+(role==='diver'?'hl-diver-dashboard':'home'));document.querySelectorAll('.hl-portal-nav-item').forEach((item,index)=>{item.classList.toggle('active',index===0);if(index===0)item.setAttribute('aria-current','page');else item.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});return true} + if(home){selected=null;document.querySelectorAll('.hl-managed-service-root').forEach(item=>item.classList.remove('hl-managed-service-root','hl-workspace-selected'));delete document.body.dataset.hlWorkspaceView;delete document.body.dataset.hlWorkspaceService;if(heading)heading.hidden=true;history.replaceState(null,'','#'+(role==='diver'?'hl-diver-dashboard':'home'));document.querySelectorAll('.hl-portal-nav-item').forEach((item,index)=>{item.classList.toggle('active',index===0);if(index===0)item.setAttribute('aria-current','page');else item.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});return true} if(node.classList.contains('hl-workspace-hidden'))return false; - while(node.parentElement&&node.parentElement!==main)node=node.parentElement; - if(node.parentElement!==main)return false; + const managedTraining=node.matches('.hl-training')?node:null; + while(!managedTraining&&node.parentElement&&node.parentElement!==main)node=node.parentElement; + if(!managedTraining&&node.parentElement!==main)return false; + if(managedTraining){let serviceRoot=managedTraining;while(serviceRoot.parentElement&&serviceRoot.parentElement!==main)serviceRoot=serviceRoot.parentElement;if(serviceRoot.parentElement!==main)return false;selected=serviceRoot;serviceRoot.classList.add('hl-workspace-selected','hl-managed-service-root');managedTraining.classList.add('hl-workspace-selected');document.body.dataset.hlWorkspaceView='service';document.body.dataset.hlWorkspaceService='hl-training-service';if(heading){heading.hidden=false;heading.querySelector('[data-service-title]').textContent=managedTraining.querySelector('h1,h2,h3')?.textContent||'مساحة التدريب'}document.querySelector('.hl-role-dashboard')?.classList.remove('hl-portal-nav-open');document.querySelector('.sidebar')?.classList.remove('open');if(!managedTraining.hasAttribute('tabindex'))managedTraining.tabIndex=-1;managedTraining.focus({preventScroll:true});window.scrollTo({top:0,behavior:'instant'});return true} selected=node;node.classList.add('hl-workspace-selected');document.body.dataset.hlWorkspaceView='service'; for(const selector of serviceGroups[node.id]||[])document.querySelectorAll(selector).forEach(item=>{if(!item.classList.contains('hl-workspace-hidden'))item.classList.add('hl-workspace-selected')}); if(heading){heading.hidden=false;heading.querySelector('[data-service-title]').textContent=node.querySelector('h1,h2,h3')?.textContent||'مساحة الخدمة'} document.querySelector('.hl-role-dashboard')?.classList.remove('hl-portal-nav-open');document.querySelector('.sidebar')?.classList.remove('open'); if(!node.hasAttribute('tabindex'))node.tabIndex=-1;node.focus({preventScroll:true});window.scrollTo({top:0,behavior:'instant'});return true; }; - const reset=()=>{selected=null;main.querySelectorAll('.hl-workspace-selected').forEach(item=>item.classList.remove('hl-workspace-selected'));delete document.body.dataset.hlWorkspaceView;const heading=document.querySelector('.hl-service-heading');if(heading)heading.hidden=true}; + const reset=()=>{selected=null;document.querySelectorAll('.hl-managed-service-root').forEach(item=>item.classList.remove('hl-managed-service-root','hl-workspace-selected'));delete document.body.dataset.hlWorkspaceService;main.querySelectorAll('.hl-workspace-selected').forEach(item=>item.classList.remove('hl-workspace-selected'));delete document.body.dataset.hlWorkspaceView;const heading=document.querySelector('.hl-service-heading');if(heading)heading.hidden=true}; document.addEventListener('click',event=>{ if(!auth()||event.defaultPrevented)return; const link=event.target.closest('a[href^="#"]');if(!link||!link.closest('#navigation,.mobile-nav'))return; diff --git a/docs/API_DEPENDENCY_BOUNDARY_2026-10-03.md b/docs/API_DEPENDENCY_BOUNDARY_2026-10-03.md new file mode 100644 index 000000000..bb7da0a1f --- /dev/null +++ b/docs/API_DEPENDENCY_BOUNDARY_2026-10-03.md @@ -0,0 +1,69 @@ +# API dependency boundary: root cause and correction + +## Causal chain + +The monorepo lock covers API, browser and Expo/React Native mobile workspaces. +Expo CLI/code-signing requires node-forge; Metro/micromatch requires braces. +Current upstream advisories cover node-forge <=1.4.0 and braces <=3.0.3, with +no published patched versions verified on 2026-10-03. The full audit's 19 high +entries include propagation through their parents, not 19 independent flaws. + +Both API Dockerfiles installed every workspace and copied the entire resulting +node_modules into production. Thus mobile tooling was physically shipped with +the API despite having no API runtime purpose. The original image also carried +development tools, and Prisma was marked dev-only despite the Render startup +command invoking its migration CLI. Changing only audit scope would leave that +physical exposure intact. + +## Implemented correction + +- Scope both API build installations to @hydroland/api with root workspace + dependencies excluded. API declares its own required AWS dependencies. +- Install production dependencies in a separate clean stage using the existing + lock. Keep mobile source, manifests, locked versions and the full audit intact. +- Classify Prisma as a runtime dependency, matching its migration-before-start + use. No resolved package versions change. TypeScript remains an optional peer + required by the installed Prisma dependency graph; Nest CLI does not ship. +- Use a common Debian/OpenSSL base for generation and runtime; copy generated + Prisma client and matching engines so runtime does not download binaries. +- Apply the same isolation to Render's root Dockerfile and Compose's API + Dockerfile, preserving each entrypoint's migration/direct-start behavior. +- Keep non-root execution and use exec after Render migrations for signal + delivery to the API process. + +## Verification + +An isolated local install contains 247 inventoried packages and no Expo, +React Native, Metro, node-forge, braces, Playwright or Nest CLI. Its API runtime +dependency audit reports zero vulnerabilities. Loading AppModule and constructing +the generated Prisma client succeeds, and the Prisma CLI finds local engines. +No resolved dependency versions changed in the lockfile. These local results do +not claim a Docker build: Docker is unavailable in this workspace. + +Phase 11 CI now builds both final images and inventories their actual installed +files, rejects mobile/browser tooling, imports the compiled application, runs +Prisma with networking disabled, and audits the API production graph in addition +to the unchanged full repository audit. It boots the root image as non-root on +a read-only filesystem and queries disposable PostgreSQL through /health/ready. +That smoke uses a canonical-schema bootstrap and a direct node entrypoint; +historical production migrations and production restore acceptance are not +claimed. Exact-head CI evidence is recorded in PR #409. + +## Remaining release blocker + +Update: the subsequently approved independent web/API and mobile release policy +is documented in RELEASE_CHANNELS_2026-10-03.md. The paragraph below records the +hold before that decision; mobile advisories remain unresolved and fully audited. + +The full repository audit must continue to fail while mobile contains the two +unpatched dependencies. Isolating the API fixes accidental shipping; it does not +repair upstream cryptography or recursive brace parsing, nor certify the mobile +application. No severity exception, renamed package, fake version, removed +mobile workspace, force-downgrade or unreviewed fork is used. The existing +all-project release hold remains. A supported patched dependency chain is still +required to clear it; arbitrary Expo upgrades retain the affected dependencies. + +References: +- https://github.com/advisories/GHSA-86w9-cpqp-85rv +- https://github.com/advisories/GHSA-vfj7-8cjw-p6xm +- https://github.com/digitalbazaar/forge/pull/1152 diff --git a/docs/CENTER_PORTAL_REVIEW_2026-10-03.md b/docs/CENTER_PORTAL_REVIEW_2026-10-03.md new file mode 100644 index 000000000..6bf17d623 --- /dev/null +++ b/docs/CENTER_PORTAL_REVIEW_2026-10-03.md @@ -0,0 +1,248 @@ +# Dive-center continuation — 2026-10-03 + +## Release remains held + +Review started at PR #409 head `9e2dbf2e59d8389137d25076a8a7c676586b5d3f`. +Main and both inspected Render deployments remain at +`830d1be035f1d4c48e9a92f8cccbf86db910b8bc`. + +The starting-head Web run `37094574096` passed 160/160 browser tests and a +separate 4/4 notification run. It retained public, portal and account screenshot +artifacts. These are the current evidence counts, superseding the older 154-test +checkpoint in the PR description; they are not a new manual visual acceptance. + +A fresh `npm audit --json` against the unchanged lockfile reported 19 high, +10 moderate and 0 critical affected dependency entries. The high roots are: + +- `node-forge <=1.4.0`, GHSA-86w9-cpqp-85rv / CVE-2026-85393. +- `braces <=3.0.3`, GHSA-vfj7-8cjw-p6xm / CVE-2026-93687. + +The npm registry still advertises node-forge 1.4.0 and braces 3.0.3 as latest. +Expo 57.0.26 depends on CLI ^57.0.27; that CLI still depends on node-forge +^1.3.3. The latest @expo/code-signing-certificates 0.0.7 still depends on +node-forge ^1.4.0, and micromatch 4.0.8 still depends on braces ^3.0.3. +Therefore a compatible published upgrade removing these blockers was not +established. The advisory entries report no fixed versions, and forge PR #1152 +remains open. Dependency manifests, lockfile, audit policy and release hold are +unchanged. No forced downgrade, package exception or unreviewed replacement is +introduced. A proposed changelog version is not a published security fix. + +Sources checked: +- https://github.com/advisories/GHSA-86w9-cpqp-85rv +- https://github.com/advisories/GHSA-vfj7-8cjw-p6xm +- https://github.com/digitalbazaar/forge/pull/1152 +- npm registry metadata queried with `npm view` for the packages above. + +## Server-side portal-role bypass repaired + +The center service previously required active OWNER/ADMIN membership in an +active center but never checked the account's DIVE_CENTER role. The access-token +guard authenticates the account/session only. A local reproduction executing the +actual service allowed a suspended-role owner to reach both protected safety +queries, with zero role checks. Browser revocation alone could not protect direct +API callers. + +The shared `managedCenter` boundary now reads the exact DIVE_CENTER assignment +and rejects every state except ACTIVE before querying center membership or data. +The existing manager membership and organization checks remain mandatory. An +unrelated active role does not substitute for the required portal role. This +matches the existing instructor-portal server authorization pattern. + +The isolated center HTTP/PostgreSQL regression now covers: + +- All twelve center routes, including the equipment write route, with DRAFT, + PENDING_REVIEW, REJECTED, SUSPENDED, ARCHIVED and absent center roles. +- Same-session role revocation and restoration, while center and manager + membership stay active; an unrelated INSTRUCTOR role remains active. +- Recovery of the eight non-equipment read routes after role restoration. +- Separate center document assets and all four regulatory record categories; + exclusion of internal memos and cross-center records; no binary content or + unnecessary administrative identifiers in list responses. +- The original safety ownership, payload minimization, staff denial and + membership/organization suspension scenarios. + +The fixtures are restricted to CI and loopback API/PostgreSQL, and are removed +after execution. The equipment denial scenarios use a nonexistent resource: +they establish role enforcement before lookup, not successful equipment writes. +Document coverage establishes metadata-list isolation, not upload, download, +signature or license-approval lifecycle acceptance. + +## Validation and remaining acceptance + +Local actual-service role matrix: 7/7 passed; denied roles never reached protected +queries. API typecheck/build, script syntax and diff whitespace checks pass. +HTTP/PostgreSQL results for this change must be taken from the updated PR's CI; +the earlier 160 browser results must not be relabeled as a run of this change. + +Whole-center acceptance remains open, including successful equipment operations +against supported schemas, document/license lifecycles, finance/reporting and +production acceptance. The dependency-security gate must pass before merge or +deployment. No production data, role, setting, migration or external provider was +modified during this continuation. + +## Equipment continuation + +The center movement endpoint bypassed the canonical equipment inspection/service +policy check on CHECK_OUT and omitted the inventory audit event. It now calls +EquipmentInspectionService.evaluate, rejects blocked check-outs, and records +EQUIPMENT_INVENTORY_MOVED in the same transaction as movement/status changes. +Configured REVIEW/DISABLED behavior remains that of the canonical inventory +service; this does not introduce a new safety policy. + +Scoped SQL now converts the bound center identifier to the actual EquipmentBarcode +organizationId column type through jsonb_populate_record. It supports canonical +TEXT and recovered UUID scope columns without casting the indexed column. + +The center HTTP suite adds real equipment for two centers and exercises lookup, +list/history isolation, manager/staff/guest writes, invalid movements, foreign +and completed trips, missing/failed/expired inspections, all seven movement types, +retirement, actor/center/movement audit linkage, and unchanged foreign equipment. +It runs against both TEXT and UUID organizationId columns. The CI-only temporary +TEXT scope has a real Organization FK; UUID mode tests query compatibility and +is not proof of production FK migration/recovery. Fixtures and temporary schema +changes are removed. Successful run counts belong in the PR's current CI evidence; +typecheck and build passed locally before submission. + +Production operation acceptance and document/license lifecycles remain open. +Security release hold is unchanged; no merge, deployment, production migration, +policy change or dependency-audit exception is authorized by this repair. + +## Document lifecycle continuation + +Review found a version race in DocumentPersistenceService.transition: after its +initial read, a concurrent DRAFT revision could commit before the transition +transaction. The transaction checked status only, so it submitted the changed +version. An actual-service local reproduction accepted version 2 after reading +version 1. The repaired transaction checks both status and version, including +both in the conditional update. The same reproduction now returns 400 without +writing a transition or event. + +A deterministic CI PostgreSQL regression invokes the real transition service and +commits the competing revision through the running HTTP API before opening the +transition transaction. It checks that stale submit leaves DRAFT version 2 and +its revision history intact, writes no approval-transition event, and that a +fresh HTTP submit succeeds with exactly one version-2 event. Existing HTTP tests +continue to cover membership isolation, separation of creator/approver/signer, +PDF rendering, pinned branding/templates, archive immutability and numbering. +This server-side race guard does not add a client expected-version contract. + +License acceptance remains separate: the center endpoint currently lists +AdministrativeRecord metadata; OrganizationDocumentAsset's implemented upload +endpoint accepts logos, not license attachments. Metadata fixtures of kind +LICENSE do not establish an upload/expiry/review/external-verification workflow. +No external license approval or qualified electronic-signature acceptance is +claimed. No production mutation, merge, deployment or security exception occurs. + +## License attachments and validity — implementation + +Existing center regulatory records now expose an optional linked attachment, +issue/expiry dates and their latest internal administrative routing outcome. +A center manager with an ACTIVE DIVE_CENTER assignment can attach a PDF/PNG/JPEG +(up to 2 MB) to an owned DRAFT regulatory record. File encoding/signature and +calendar dates are checked; PDFs must parse and contain a page. Server SHA-256, +scoped deduplication, conditional record update and actor audit commit together. +REGISTERED/ARCHIVED records reject attachment/date changes. Old files are retained. + +Private downloads follow record and center ownership, verify the stored digest, +and use attachment disposition with private/no-store caching. The web panel +reauthorizes before upload/download, discards stale session responses, shows +validity independently of internal review, and locks the upload form after +registration. Internal review is not issuer/regulator verification. These checks +are not a malware-scanning service. + +The forward migration adds nullable asset/date fields, preserves legacy records, +and follows the actual asset key type (TEXT/UUID). Its isolated database test +runs the exact migration on both key types and verifies the FK, ordered dates, +asset deletion restriction and legacy nulls. Deployment of the migration has NOT +occurred; code and schema must be released together after the security hold clears. + +HTTP tests exercise bad input, scope/role denial, exact download bytes, tamper +rejection, deduplication, immutable registered dates, audit linkage and the +existing register/route/assign/independent-review workflow. Browser tests cover +upload payload/refresh, expiry versus internal approval, read-only registered +records and revoked-role upload/download denial. + +Scope: this adds attachments to existing DRAFT regulatory records. It does not +add center-side record creation, renewal or review-assignment screens; those +remain the next UI work, using existing administrative workflow boundaries. +No external approval, production data or production service was changed. + +## Center-side license creation and renewal + +The portal now creates regulatory DRAFT records in an active unit belonging to +the managed center. Organization, owner and initial status come from server +context; callers cannot inject another center, owner, attachment or approval. +Reference/subject/type are validated, duplicate references return 409, and record +creation plus actor audit is atomic. The documents response supplies only active +scoped unit choices. + +Renewal is a separate endpoint for an owned non-DRAFT regulatory record. It +creates a new DRAFT with a new reference, inherits the source type/unit, and +copies no old attachment, validity dates, routing or approval. The old record +is untouched. CENTER_LICENSE_RENEWAL_CREATED records the source record ID and +reference as provenance in the append-only audit; no new schema migration is +needed for this continuation. + +The UI offers new-record creation, renewal, duplicate-error correction and +immediate attachment editing on the new draft, with fresh authorization before +every write. The HTTP matrix now denies inactive roles across 16 center routes; +additional real-DB checks cover foreign/inactive units, spoofing, concurrent +reference collisions, renewal provenance and old-record preservation. Browser +checks exercise creation, renewal, duplicate feedback and role revocation. + +Remaining UI work: submission/registration and reviewer assignment/decision, +reusing existing administrative permissions and separation of duties. Creation +or renewal is not a license approval. Production deployment and the earlier +attachment migration remain held by dependency security. + +## Center license review UI and scoped actions + +The portal now exposes registration (which locks the attachment), routing to a +separate active center unit, reviewer assignment and APPROVE/REJECT decisions. +Registration and routing are explicit separate actions, not a claimed atomic +combined submission. Four fresh-role/managed-center endpoints scope the record +or routing before delegating to AdministrativeAffairsPersistenceService. They +reuse canonical permissions, conditional writes, audit events and requester / +assigned-reviewer separation of duties. No independent approval engine is added. + +Center registration requires an attached file with dates and verified digest. +Reviewer choices are active OWNER/ADMIN members with active accounts and an +ACTIVE DIVE_CENTER role whose selected managed center is this center. Scoped +administrative reviewers outside this portal subset keep their existing admin +workflow; no role is granted automatically. Assignment rechecks eligibility and +rejects the requester. Only the assigned non-requester can decide; decisions +remain immutable through the canonical workflow. + +All review records are shown latest first, not only the latest row. Each row +has its unit/reviewer display name, eligible assignment choices and a server +computed canDecide flag; raw requester/assignee fields are omitted from the +list. Historical decisions remain visible when another review is requested. +The UI hides final decision controls and reauthorizes before each of the four +mutations. Internal review is not issuer/regulatory approval. + +Coverage adds real HTTP/DB registration, missing-file denial, destination scope, +requester/foreign/ineligible assignee denial, approval and rejection, immutable +final decisions and canonical reviewer audit. Inactive-role coverage now spans +20 routes. Browser tests cover ordered register/route/assign, both decisions, +and per-action same-session role revocation. No new migration is required. +The original attachment migration remains pending deployment with the code; +production and dependency-security release hold are unchanged. + +## Registration versus concurrent attachment edits + +Center registration previously verified the attachment before calling canonical +registration, whose DRAFT-only update could accept a different attachment or +validity dates committed in between. The center wrapper now passes the verified +record's updatedAt, attachment ID and both dates to the canonical atomic update. +A mismatch returns 409 before the registration audit is created. Existing +administrative callers keep their current contract when no license snapshot is +supplied; this is a server-side interleaving guard, not a client revision token. + +Two deterministic service/PostgreSQL checks commit a competing attachment edit +through the HTTP API: one before the canonical record read and one immediately +before the registration transaction. They cover changed file and dates even at +equal timestamp precision, retained DRAFT/edited content, absence of a false +registration audit, and successful fresh HTTP retry with exactly one audit. +They run within the existing center HTTP/PostgreSQL CI suite. Local typecheck, +API build and script syntax checks passed; exact commit CI evidence belongs in +PR #409. No schema, dependency, release gate or production change is included. diff --git a/docs/RELEASE_CHANNELS_2026-10-03.md b/docs/RELEASE_CHANNELS_2026-10-03.md new file mode 100644 index 000000000..b4e4a12eb --- /dev/null +++ b/docs/RELEASE_CHANNELS_2026-10-03.md @@ -0,0 +1,75 @@ +# Independently gated release channels + +Approved continuation on 2026-10-03 after the user was asked to separate the +web/API release from the blocked native mobile release. This supersedes the +previous all-project dependency hold for web/API only. It does not accept, +waive, suppress or mark the mobile advisories fixed. + +| Channel | Dependency scope, including dev/build tools | Security evidence | +| --- | --- | --- | +| Web/API | apps/api, apps/web, root dependencies | Security audit / audit; Dependency lock audit / lock-and-audit | +| Mobile | apps/mobile and all transitive dependencies | Mobile release security / mobile-security | +| Repository visibility | Entire existing root lock and all workspaces | repository-audit.json in mobile security artifacts | + +All scopes use the same fail-closed validator. Registry failures, invalid JSON, +missing/invalid severity counts, malformed vulnerability rows and high/critical +findings fail. There are no package-name exceptions or severity changes. The +mobile workflow and full repository audit intentionally remain red until the +upstream dependency chain is actually fixed; their evidence is retained even +on failure. No continue-on-error or success substitution is introduced. + +## Matching release construction + +Web/API RC and production validation install/test/typecheck/build only those +workspaces and root dependencies. API/browser validation uses its own scoped +install. Mobile keeps independent functional validation and a security blocker. +The shared lock remains committed and unfiltered; lock validation uses npm ci +and rejects mutation instead of silently regenerating resolution during CI. +Both API Dockerfiles retain the tested clean runtime dependency stage and +actual installed-file inventory checks from the preceding architectural fix. + +The manually triggered Pages pipeline installs only web/root dependencies, +runs web validation and the web/API dependency audit before uploading its static +artifact. Existing Render deployments/configuration are not changed by this +continuation. Before any production release, verify the target service builds +only its approved workspace and the exact tested commit. This document is not +proof of production deployment, migration success or external acceptance. + +## Merge and deployment controls + +Web/API still requires exact-head API and browser tests, API image boundary and +runtime checks, RC/production validation, main integrity and the applicable +existing regressions, plus clean web/API dependency gates. A mobile release +requires BOTH Mobile validation and Mobile release security to succeed. There +is currently no mobile deployment workflow; any future one must require both. + +The dedicated branch-protection endpoint returned 403. Subsequent permitted +reads of branches/main and repository rulesets returned protected:false, +required-check enforcement off, empty contexts/checks and no rulesets. No branch +rules or bypass settings were changed. The approved release gates must still be +verified explicitly; absence of branch protection does not waive them. + +## Native Render configuration still to apply + +Live read-only service inspection found both services use native Node, not the +Docker Blueprint. Both auto-deploy main on commit. The API rootDir is apps/api +with build `npm install --include=dev && npx prisma generate && npm run build`; +web builds from root using `cd apps/web && npm run build`. Thus validating only +the Dockerfiles does not establish live build isolation. + +The prepared replacement build commands are: + +- API service srv-dakelt142hec73aavvsg: `node ../../scripts/build-api-release.mjs` +- Web service srv-daknr0tbvr0c73ea69a0: `node scripts/build-web-release.mjs` + +Both commands use committed scoped installs and the release audit; the API +command prunes development tools and verifies installed runtime contents while +retaining migration engines. Existing start commands and environment values are +unchanged. Phase 11 CI executes these exact scripts, verifies native API readiness +against disposable PostgreSQL, and retains both audit reports. Docker checks are +retained too. The Render connector cannot update build commands. Applying these +settings therefore needs an approved supported fallback; no merge/deploy should +occur before the production build path is aligned and the exact head passes. + +The API boundary report is at API_DEPENDENCY_BOUNDARY_2026-10-03.md; exact commit +workflow results are recorded in PR #409. diff --git a/package-lock.json b/package-lock.json index e97f391e0..babba65bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,13 +34,13 @@ "bidi-js": "^1.0.3", "google-auth-library": "^11.1.0", "pdf-lib": "^1.17.1", + "prisma": "^6.19.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, "devDependencies": { "@nestjs/cli": "^11.0.0", "@types/node": "^22.10.0", - "prisma": "^6.19.3", "typescript": "^5.7.0" } }, @@ -3041,7 +3041,6 @@ }, "node_modules/@prisma/config": { "version": "6.19.3", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -3052,12 +3051,10 @@ }, "node_modules/@prisma/debug": { "version": "6.19.3", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3069,12 +3066,10 @@ }, "node_modules/@prisma/engines-version": { "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -3084,7 +3079,6 @@ }, "node_modules/@prisma/get-platform": { "version": "6.19.3", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -4064,7 +4058,6 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "devOptional": true, "license": "MIT" }, "node_modules/@tokenizer/inflate": { @@ -4878,7 +4871,6 @@ }, "node_modules/c12": { "version": "3.1.0", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -4985,7 +4977,6 @@ }, "node_modules/chokidar": { "version": "4.0.3", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5040,7 +5031,6 @@ }, "node_modules/citty": { "version": "0.1.6", - "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -5236,7 +5226,6 @@ }, "node_modules/confbox": { "version": "0.2.4", - "devOptional": true, "license": "MIT" }, "node_modules/connect": { @@ -5305,7 +5294,6 @@ }, "node_modules/consola": { "version": "3.4.2", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -5457,7 +5445,6 @@ }, "node_modules/deepmerge-ts": { "version": "8.0.2", - "devOptional": true, "funding": [ { "type": "ko-fi", @@ -5485,7 +5472,6 @@ }, "node_modules/defu": { "version": "6.1.7", - "devOptional": true, "license": "MIT" }, "node_modules/depd": { @@ -5497,7 +5483,6 @@ }, "node_modules/destr": { "version": "2.0.5", - "devOptional": true, "license": "MIT" }, "node_modules/destroy": { @@ -5525,7 +5510,6 @@ }, "node_modules/dotenv": { "version": "16.6.1", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5561,7 +5545,6 @@ }, "node_modules/effect": { "version": "3.21.0", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -5578,7 +5561,6 @@ }, "node_modules/empathic": { "version": "2.0.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -6405,7 +6387,6 @@ }, "node_modules/exsolve": { "version": "1.1.1", - "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -6416,7 +6397,6 @@ }, "node_modules/fast-check": { "version": "3.23.2", - "devOptional": true, "funding": [ { "type": "individual", @@ -6775,7 +6755,6 @@ }, "node_modules/giget": { "version": "2.0.0", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -7197,7 +7176,6 @@ }, "node_modules/jiti": { "version": "2.7.0", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -8085,7 +8063,6 @@ }, "node_modules/node-fetch-native": { "version": "1.6.7", - "devOptional": true, "license": "MIT" }, "node_modules/node-forge": { @@ -8125,7 +8102,6 @@ }, "node_modules/nypm": { "version": "0.6.10", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -8141,7 +8117,6 @@ }, "node_modules/nypm/node_modules/citty": { "version": "0.2.2", - "devOptional": true, "license": "MIT" }, "node_modules/ob1": { @@ -8173,7 +8148,6 @@ }, "node_modules/ohash": { "version": "2.0.12", - "devOptional": true, "license": "MIT" }, "node_modules/on-finished": { @@ -8349,7 +8323,6 @@ }, "node_modules/pathe": { "version": "2.0.3", - "devOptional": true, "license": "MIT" }, "node_modules/pdf-lib": { @@ -8368,7 +8341,6 @@ }, "node_modules/perfect-debounce": { "version": "1.0.0", - "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8387,7 +8359,6 @@ }, "node_modules/pkg-types": { "version": "2.3.3", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", @@ -8397,7 +8368,6 @@ }, "node_modules/pkg-types/node_modules/confbox": { "version": "0.3.1", - "devOptional": true, "license": "MIT" }, "node_modules/playwright": { @@ -8513,7 +8483,6 @@ }, "node_modules/prisma": { "version": "6.19.3", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -8592,7 +8561,6 @@ }, "node_modules/pure-rand": { "version": "6.1.0", - "devOptional": true, "funding": [ { "type": "individual", @@ -8661,7 +8629,6 @@ }, "node_modules/rc9": { "version": "2.1.2", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -8903,7 +8870,6 @@ }, "node_modules/readdirp": { "version": "4.1.2", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -9689,7 +9655,6 @@ }, "node_modules/tinyexec": { "version": "1.3.1", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/scripts/build-api-release.mjs b/scripts/build-api-release.mjs new file mode 100644 index 000000000..3ea00bda5 --- /dev/null +++ b/scripts/build-api-release.mjs @@ -0,0 +1,17 @@ +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +// Render rootDir is apps/api; resolve from this file rather than caller cwd. +process.chdir(fileURLToPath(new URL('../',import.meta.url))); +const run=(command,args)=>{ + const result=spawnSync(command,args,{stdio:'inherit'}); + if(result.error)throw result.error; + if(result.status!==0)throw new Error(`${command} ${args.join(' ')} failed (${result.status})`); +}; +const scope=['--workspace=@hydroland/api','--include-workspace-root=false']; +run('npm',['ci',...scope,'--include=dev','--ignore-scripts']); +run('npm',['run','db:generate','--workspace=@hydroland/api']); +run('npm',['run','build','--workspace=@hydroland/api']); +run('npm',['prune',...scope,'--omit=dev','--ignore-scripts']); +run('node',['apps/api/scripts/runtime-dependency-boundary.mjs']); +run('node',['scripts/release-dependency-audit.mjs','web-api','api-release-audit.json']); diff --git a/scripts/build-web-release.mjs b/scripts/build-web-release.mjs new file mode 100644 index 000000000..a6a035159 --- /dev/null +++ b/scripts/build-web-release.mjs @@ -0,0 +1,13 @@ +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +process.chdir(fileURLToPath(new URL('../',import.meta.url))); +const run=(command,args)=>{ + const result=spawnSync(command,args,{stdio:'inherit'}); + if(result.error)throw result.error; + if(result.status!==0)throw new Error(`${command} ${args.join(' ')} failed (${result.status})`); +}; +run('npm',['ci','--workspace=@hydroland/web','--include-workspace-root=true','--ignore-scripts']); +run('npm',['run','test','--workspace=@hydroland/web']); +run('npm',['run','build','--workspace=@hydroland/web']); +run('node',['scripts/release-dependency-audit.mjs','web-api','web-release-audit.json']); diff --git a/scripts/release-dependency-audit.mjs b/scripts/release-dependency-audit.mjs new file mode 100644 index 000000000..ff8f57754 --- /dev/null +++ b/scripts/release-dependency-audit.mjs @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import {pathToFileURL} from 'node:url'; + +export function validateAuditReport(report,status){ + const counts=report?.metadata?.vulnerabilities; + if(![0,1].includes(status)||report?.error||!counts|| + !Number.isInteger(counts.high)||!Number.isInteger(counts.critical)|| + counts.high<0||counts.critical<0||!report.vulnerabilities|| + typeof report.vulnerabilities!=='object'||Array.isArray(report.vulnerabilities)|| + Object.values(report.vulnerabilities).some(v=>!v||typeof v!=='object'||!['info','low','moderate','high','critical'].includes(v.severity))){ + throw new Error('Dependency audit did not return a complete valid report.'); + } + const rows=Object.entries(report.vulnerabilities).filter(([,v])=>['high','critical'].includes(v?.severity)); + if(rows.length||counts.high||counts.critical)throw new Error('High/critical dependency vulnerabilities remain. No package-name exceptions are permitted.'); + return counts; +} + +export const auditScopes={ + 'web-api':['--workspace=@hydroland/api','--workspace=@hydroland/web','--include-workspace-root=true'], + mobile:['--workspace=@hydroland/mobile','--include-workspace-root=false'], + repository:[], +}; + +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + const scope=process.argv[2],output=process.argv[3]; + try{ + if(!Object.hasOwn(auditScopes,scope)||!output)throw new Error('Expected an explicit web-api, mobile or repository scope and report path.'); + // Include development dependencies: build tooling belongs to its release. + const result=spawnSync('npm',['audit',...auditScopes[scope],'--include=dev','--json'],{encoding:'utf8',maxBuffer:16*1024*1024}); + fs.writeFileSync(output,result.stdout||''); + if(result.error)throw result.error; + const report=JSON.parse(result.stdout); + console.log(JSON.stringify({scope,counts:report.metadata?.vulnerabilities,affected:Object.keys(report.vulnerabilities||{})},null,2)); + validateAuditReport(report,result.status); + console.log(`Dependency security passed for ${scope}.`); + }catch(error){console.error(`Dependency security failed for ${scope}: ${error.message}`);process.exitCode=1;} +} diff --git a/scripts/release-dependency-audit.test.mjs b/scripts/release-dependency-audit.test.mjs new file mode 100644 index 000000000..ca0e3d89b --- /dev/null +++ b/scripts/release-dependency-audit.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import {validateAuditReport,auditScopes} from './release-dependency-audit.mjs'; +const valid=()=>({metadata:{vulnerabilities:{high:0,critical:0}},vulnerabilities:{}}); +assert.doesNotThrow(()=>validateAuditReport(valid(),0)); +const moderate=valid();moderate.vulnerabilities.example={severity:'moderate'}; +assert.doesNotThrow(()=>validateAuditReport(moderate,1)); +for(const report of [null,{},[],{error:{message:'registry unavailable'}}, + {...valid(),vulnerabilities:[]}, + {...valid(),vulnerabilities:{malformed:null}}, + {...valid(),vulnerabilities:{malformed:{}}}, + {...valid(),metadata:{vulnerabilities:{high:'0',critical:0}}}, + {...valid(),metadata:{vulnerabilities:{high:-1,critical:0}}}, + {...valid(),metadata:{vulnerabilities:{high:1,critical:0}}}, + {...valid(),metadata:{vulnerabilities:{high:0,critical:1}}}, + {...valid(),vulnerabilities:{'node-forge':{severity:'high'}}}, + {...valid(),vulnerabilities:{unknown:{severity:'critical'}}}]){ + assert.throws(()=>validateAuditReport(report,1)); +} +for(const status of [null,2,127])assert.throws(()=>validateAuditReport(valid(),status)); +assert.deepEqual(auditScopes.repository,[],'Full repository coverage remains unfiltered'); +assert.ok(auditScopes.mobile.includes('--workspace=@hydroland/mobile')); +assert.ok(auditScopes['web-api'].includes('--include-workspace-root=true')); +console.log('Release audit contract passed: clean/moderate reports, fail-closed errors, severity counts/rows and retained full/mobile coverage.');