diff --git a/.github/workflows/f4-ci.yml b/.github/workflows/f4-ci.yml new file mode 100644 index 0000000..949307a --- /dev/null +++ b/.github/workflows/f4-ci.yml @@ -0,0 +1,89 @@ +name: F4 Document Flow CI + +on: + push: + branches: + - 'feature/f4*' + pull_request: + branches: + - main + paths: + - 'api-v3/**' + - 'web-v2/**' + - 'android-app/**' + - 'scripts/**' + - '.github/workflows/f4-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + api: + name: API · F4 + runs-on: ubuntu-latest + timeout-minutes: 25 + defaults: + run: + working-directory: api-v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: api-v3/package-lock.json + - run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Tests + run: npm test + - name: Build + run: npm run build + + web: + name: WEB · regression + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: web-v2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: web-v2/package-lock.json + - run: npm ci + - run: npm run typecheck + - name: F3.1 structural WEB contract + run: bash ../scripts/check-f3-1-web-contract.sh + - run: npm run build + + deploy-preflight: + name: VPS-equivalent preflight / Docker + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [api, web] + steps: + - uses: actions/checkout@v4 + - name: Validate shell scripts + run: | + while IFS= read -r -d '' script; do + bash -n "$script" + done < <(find scripts -type f -name '*.sh' -print0) + - name: Validate Compose + run: docker compose --env-file .env.example config >/dev/null + - name: VPS-equivalent isolated API tests + run: | + set -Eeuo pipefail + image="dhv2-api:f4-preflight-${GITHUB_SHA::12}" + docker build --target builder -t "$image" api-v3 + docker run --rm \ + -v "$PWD/api-v3/test:/app/test:ro" \ + -v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + "$image" npm test + docker image rm "$image" >/dev/null 2>&1 || true + - name: Build production images + run: docker compose --env-file .env.example build api migrate web diff --git a/api-v3/src/inspection-visits/inspection-visit-code.ts b/api-v3/src/inspection-visits/inspection-visit-code.ts index 3b48b52..a598790 100644 --- a/api-v3/src/inspection-visits/inspection-visit-code.ts +++ b/api-v3/src/inspection-visits/inspection-visit-code.ts @@ -4,14 +4,15 @@ export async function nextInspectionVisitCode( manager: EntityManager, plannedStartAt: Date, ): Promise { - const [yearRow] = (await manager.query(` - SELECT EXTRACT( - YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza' - )::integer AS year - `, [plannedStartAt])) as Array<{ year: number }>; - const year = Number(yearRow?.year); - if (!Number.isInteger(year) || year < 2000 || year > 9999) { - throw new Error('Unable to derive inspection visit year'); + const [dateRow] = (await manager.query(` + SELECT + EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS "datePart" + `, [plannedStartAt])) as Array<{ year: number; datePart: string }>; + const year = Number(dateRow?.year); + const datePart = dateRow?.datePart; + if (!Number.isInteger(year) || year < 2000 || year > 9999 || !datePart) { + throw new Error('Unable to derive inspection visit date'); } await manager.query( @@ -20,15 +21,25 @@ export async function nextInspectionVisitCode( ); const [sequenceRow] = (await manager.query(` - SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence - FROM inspection_visits - WHERE code LIKE $1 - AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$') - `, [`INS-${year}-%`, year])) as Array<{ sequence: number }>; + SELECT COALESCE(MAX(candidate.sequence), 0)::integer AS sequence + FROM ( + SELECT SUBSTRING(code FROM '^INSP-([0-9]{5})-[0-9]{2}-[0-9]{2}-[0-9]{2}$')::integer AS sequence + FROM inspection_visits + WHERE EXTRACT( + YEAR FROM COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza' + )::integer = $1 + AND code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$' + UNION ALL + SELECT RIGHT(code, 6)::integer AS sequence + FROM inspection_visits + WHERE code LIKE $2 + AND code ~ ('^INS-' || $1::text || '-[0-9]{6}$') + ) candidate + `, [year, `INS-${year}-%`])) as Array<{ sequence: number }>; const sequence = Number(sequenceRow?.sequence ?? 0) + 1; - if (sequence > 999999) { + if (sequence > 99999) { throw new Error(`Inspection visit sequence exhausted for ${year}`); } - return `INS-${year}-${String(sequence).padStart(6, '0')}`; + return `INSP-${String(sequence).padStart(5, '0')}-${datePart}`; } diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 5fb0c86..5011bce 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -13,7 +13,6 @@ import { import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; @@ -135,17 +134,6 @@ export class InspectionVisitsController { return this.visits.replaceTeam(id, dto, principal, request); } - @Put(':id/status') - @RequirePermissions('inspections.manage') - changeStatus( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeInspectionVisitStatusDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.visits.changeStatus(id, dto, principal, request); - } - @Post(':id/start') @RequirePermissions('inspections.execute') start(