Просмотр исходного кода

Let technicians check a vehicle in from the app (#553)

* Let technicians check a vehicle in from the app

The technician app's API gains a job check-in: the mileage, the drop-off
photos by position, and the condition map with every mark the web's
Drop-off tab shows, numbered the same way, drawn from paths the server
sends. Marks are drawn, changed, removed and photographed, earlier
visits' marks confirmed or cleared, all under the rules the web's
actions use, which now share one mark store. A job booked ahead is on
the app's list from its day on, and /me names the check-in feature so
an older server hides it.

* Keep checked-in bookings, close-ups and live maps right

A booking's first check-in write moves it from scheduled to pending, so
a customer cancelling the online booking no longer deletes a work order
whose car is in the yard. Close-ups are added to a mark under a row
lock with the limit in the same step, so two uploads at once both land.
Undoing a cleared mark is limited to marks this check-in could clear.
Condition marks now announce the work order or inspection they are drawn
on, and clearing an earlier visit's mark tells the current job, so an
open Drop-off tab redraws.

* Write mark clears and photo removals only in the state they were read

Clearing or reopening an earlier visit's mark from the app is a
compare-and-set on its closed state, so a confirm at the desk in the
meantime is answered with a conflict instead of being undone. Removing
a close-up reads and writes the mark under the same row lock an upload
takes, and a mark of an unknown kind is refused before a booking is
treated as arrived.

* Scope the mileage write by workshop and read the map words once
Bernt Christian Egeland 1 день назад
Родитель
Сommit
79df405dc8

+ 567 - 0
src/__tests__/api/tech-checkin.test.ts

@@ -0,0 +1,567 @@
+/**
+ * @vitest-environment node
+ *
+ * Checking a car in from the technician app.
+ *
+ * The app reads the job's check-in (the map, the marks, the drop-off photos),
+ * draws marks, and confirms or clears what earlier visits found. Each of
+ * those is a record the desk and the print rely on, so the rules have to be
+ * the web's: a mark is drawn on this job's drop-off, numbered as the web
+ * numbers it, an earlier visit's mark is never edited from here, and nobody
+ * reaches a job that is not theirs.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+type Mark = {
+  id: string
+  organizationId: string
+  vehicleId: string
+  inspectionId: string | null
+  inspectionItemId: string | null
+  serviceRecordId: string | null
+  bodyType: string
+  view: string
+  panel: string
+  x: number
+  y: number
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  recordedAt: Date
+  resolvedAt: Date | null
+  carriedToId: string | null
+}
+
+const JOB = {
+  id: 'job-1',
+  organizationId: 'org-1',
+  vehicleId: 'veh-1',
+  technicianId: 'tech-1',
+  status: 'scheduled',
+  mileage: null as number | null,
+  inspectionId: null,
+  invoiceNumber: 'WO-1',
+  createdAt: new Date('2026-11-09T08:00:00Z'),
+}
+const OLD_JOB = { ...JOB, id: 'job-0', createdAt: new Date('2026-05-01T08:00:00Z') }
+
+const state = {
+  marks: [] as Mark[],
+  vehicle: {
+    id: 'veh-1',
+    organizationId: 'org-1',
+    make: 'Toyota',
+    model: 'Hilux',
+    year: 2018,
+    licensePlate: 'AB12345',
+    mileage: 120000,
+    bodyType: null as string | null,
+  },
+  attachments: [] as {
+    id: string
+    description: string
+    fileUrl: string
+    createdAt: Date
+    category: string
+  }[],
+  ctx: { userId: 'user-1', organizationId: 'org-1', technicianIds: ['tech-1'], isAdmin: false },
+}
+
+function mark(over: Partial<Mark>): Mark {
+  return {
+    id: 'm',
+    organizationId: 'org-1',
+    vehicleId: 'veh-1',
+    inspectionId: null,
+    inspectionItemId: null,
+    serviceRecordId: JOB.id,
+    bodyType: 'pickup',
+    view: 'left',
+    panel: 'left_front_door',
+    x: 0.5,
+    y: 0.5,
+    kind: 'dent',
+    severity: 'minor',
+    note: null,
+    imageUrls: [],
+    recordedAt: new Date('2026-11-09T09:00:00Z'),
+    resolvedAt: null,
+    carriedToId: null,
+    ...over,
+  }
+}
+
+const jobs = [JOB, OLD_JOB]
+const matches = (row: Record<string, unknown>, where: Record<string, unknown>) =>
+  Object.entries(where).every(([k, v]) => {
+    if (v && typeof v === 'object' && 'in' in (v as object))
+      return (v as { in: unknown[] }).in.includes(row[k])
+    if (v && typeof v === 'object' && 'not' in (v as object))
+      return row[k] !== (v as { not: unknown }).not
+    if (v && typeof v === 'object' && 'lt' in (v as object))
+      return (row[k] as number) < (v as { lt: number }).lt
+    return row[k] === v
+  })
+
+vi.mock('@/lib/db', () => {
+  const db = {
+    serviceRecord: {
+      findFirst: vi.fn(
+        async ({
+          where,
+          select,
+        }: {
+          where: Record<string, unknown>
+          select: Record<string, unknown>
+        }) => {
+          const job = jobs.find((j) => matches(j as Record<string, unknown>, where))
+          if (!job) return null
+          if (select.vehicle) {
+            return {
+              ...job,
+              vehicle: state.vehicle,
+              attachments: state.attachments.filter((a) => a.category === 'dropoff'),
+            }
+          }
+          return job
+        }
+      ),
+      update: vi.fn(async ({ data }: { data: { mileage: number | null } }) => {
+        JOB.mileage = data.mileage
+        return JOB
+      }),
+      updateMany: vi.fn(
+        async ({
+          where,
+          data,
+        }: {
+          where: Record<string, unknown>
+          data: Record<string, unknown>
+        }) => {
+          const job = jobs.find((j) => matches(j as Record<string, unknown>, where))
+          if (!job) return { count: 0 }
+          Object.assign(job, data)
+          return { count: 1 }
+        }
+      ),
+    },
+    vehicle: {
+      updateMany: vi.fn(
+        async ({
+          where,
+          data,
+        }: {
+          where: Record<string, unknown>
+          data: Record<string, unknown>
+        }) => {
+          if (!matches(state.vehicle as Record<string, unknown>, where)) return { count: 0 }
+          Object.assign(state.vehicle, data)
+          return { count: 1 }
+        }
+      ),
+    },
+    inspection: { findFirst: vi.fn(async () => null) },
+    appSetting: { findFirst: vi.fn(async () => null), findMany: vi.fn(async () => []) },
+    conditionMarkType: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
+    conditionMark: {
+      findMany: vi.fn(async () =>
+        state.marks.map((m) => ({
+          ...m,
+          // When each visit was opened: this job, an earlier one, a later one.
+          serviceRecord: {
+            createdAt:
+              m.serviceRecordId === JOB.id
+                ? JOB.createdAt
+                : m.serviceRecordId === 'job-later'
+                  ? new Date('2026-11-20T08:00:00Z')
+                  : OLD_JOB.createdAt,
+          },
+          inspection: null,
+        }))
+      ),
+      findFirst: vi.fn(
+        async ({ where }: { where: Record<string, unknown> }) =>
+          state.marks.find((m) => matches(m as Record<string, unknown>, where)) ?? null
+      ),
+      create: vi.fn(async ({ data }: { data: Partial<Mark> }) => {
+        const created = mark({
+          ...data,
+          id: `m${state.marks.length + 1}`,
+          recordedAt: data.recordedAt ?? new Date(),
+        })
+        state.marks.push(created)
+        return created
+      }),
+      update: vi.fn(
+        async ({
+          where,
+          data,
+        }: {
+          where: { id: string }
+          data: Omit<Partial<Mark>, 'imageUrls'> & { imageUrls?: string[] | { push: string } }
+        }) => {
+          const m = state.marks.find((x) => x.id === where.id) as Mark
+          const { imageUrls, ...rest } = data
+          Object.assign(m, rest)
+          if (imageUrls) {
+            m.imageUrls = Array.isArray(imageUrls) ? imageUrls : [...m.imageUrls, imageUrls.push]
+          }
+          return m
+        }
+      ),
+      updateMany: vi.fn(
+        async ({ where, data }: { where: Record<string, unknown>; data: Partial<Mark> }) => {
+          const found = state.marks.filter((m) => matches(m as Record<string, unknown>, where))
+          for (const m of found) Object.assign(m, data)
+          return { count: found.length }
+        }
+      ),
+      delete: vi.fn(async ({ where }: { where: { id: string } }) => {
+        state.marks = state.marks.filter((m) => m.id !== where.id)
+      }),
+    },
+    serviceAttachment: {
+      count: vi.fn(async () => state.attachments.length),
+      create: vi.fn(
+        async ({ data }: { data: { description: string; fileUrl: string; category: string } }) => {
+          const row = { id: `a${state.attachments.length + 1}`, createdAt: new Date(), ...data }
+          state.attachments.push(row)
+          return row
+        }
+      ),
+    },
+    $transaction: vi.fn(async (run: (tx: unknown) => Promise<unknown>) => run(db)),
+    $queryRaw: vi.fn(async () => []),
+  }
+  return { db }
+})
+
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn(async () => undefined) }))
+vi.mock('@/lib/realtime/publish.server', () => ({ publishRecordChange: vi.fn() }))
+vi.mock('@/features/vehicles/Lib/jobEvents', () => ({ announceJobStatusChanged: vi.fn() }))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => undefined),
+  discardUnsavedUpload: vi.fn(async () => undefined),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: vi.fn(async () => 'Europe/Oslo') }))
+vi.mock('@/i18n/locale-from-request', () => ({
+  getBestLocaleFromHeader: (h: string | null) =>
+    h?.startsWith('nb') ? 'nb' : h ? 'en' : undefined,
+  resolveDocumentLocale: vi.fn(async () => 'en'),
+}))
+vi.mock('@/lib/with-api-auth', async () => {
+  const actual = await vi.importActual<typeof import('@/lib/with-api-auth')>('@/lib/with-api-auth')
+  return {
+    ...actual,
+    withApiAuth: vi.fn(async (_req: Request, handler: (ctx: unknown) => Promise<Response>) =>
+      handler(state.ctx)
+    ),
+  }
+})
+
+import { GET, PATCH } from '@/app/api/v1/tech/jobs/[id]/checkin/route'
+import { POST as addMark } from '@/app/api/v1/tech/jobs/[id]/checkin/marks/route'
+import {
+  DELETE as removeMark,
+  PATCH as changeMark,
+} from '@/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/route'
+import { POST as resolve } from '@/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/resolve/route'
+import { POST as carry } from '@/app/api/v1/tech/jobs/[id]/checkin/marks/carry/route'
+import { POST as addPhoto } from '@/app/api/v1/tech/jobs/[id]/checkin/photos/route'
+import { POST as addMarkPhoto } from '@/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/photos/route'
+import { publishRecordChange } from '@/lib/realtime/publish.server'
+import { db } from '@/lib/db'
+
+const req = (body?: unknown, init: RequestInit & { lang?: string } = {}) =>
+  new Request('http://app.test/api', {
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json',
+      ...(init.lang ? { 'accept-language': init.lang } : {}),
+    },
+    body: body === undefined ? undefined : JSON.stringify(body),
+  })
+const job = (id = JOB.id) => ({ params: Promise.resolve({ id }) })
+const onMark = (markId: string) => ({ params: Promise.resolve({ id: JOB.id, markId }) })
+const read = async (res: Response) => ({ status: res.status, body: await res.json() })
+
+beforeEach(() => {
+  state.marks = []
+  state.attachments = []
+  state.vehicle.mileage = 120000
+  state.vehicle.bodyType = null
+  state.ctx = {
+    userId: 'user-1',
+    organizationId: 'org-1',
+    technicianIds: ['tech-1'],
+    isAdmin: false,
+  }
+  JOB.mileage = null
+  JOB.status = 'scheduled'
+})
+
+describe('reading a check-in', () => {
+  it('draws the vehicle, numbers earlier marks before this visit’s, and names panels in the app’s language', async () => {
+    state.marks = [
+      mark({ id: 'now', recordedAt: new Date('2026-11-09T09:00:00Z') }),
+      mark({
+        id: 'before',
+        serviceRecordId: OLD_JOB.id,
+        panel: 'hood',
+        view: 'top',
+        recordedAt: new Date('2026-05-01T09:00:00Z'),
+      }),
+    ]
+    const { status, body } = await read(
+      await GET(new Request('http://app.test/api', { headers: { 'accept-language': 'nb' } }), job())
+    )
+    expect(status).toBe(200)
+    const data = body.data
+    expect(data.sheet.body).toBe('sedan')
+    expect(data.sheet.panels.length).toBeGreaterThan(20)
+    expect(data.sheet.views.map((v: { view: string }) => v.view)).toEqual(
+      expect.arrayContaining(['top', 'left', 'right', 'front', 'rear'])
+    )
+    expect(
+      data.marks.map((m: { id: string; number: number; previous: boolean; editable: boolean }) => [
+        m.id,
+        m.number,
+        m.previous,
+        m.editable,
+      ])
+    ).toEqual([
+      ['before', 1, true, false],
+      ['now', 2, false, true],
+    ])
+    // Norwegian, from the app's own language rather than the workshop's.
+    expect(data.marks[0].panelName).toBe('Panser')
+  })
+
+  it('does not open a job that is not on the technician’s list', async () => {
+    state.ctx = { ...state.ctx, technicianIds: ['someone-else'] }
+    expect((await GET(new Request('http://app.test/api'), job())).status).toBe(404)
+  })
+})
+
+describe('a booking checked in', () => {
+  it('stops being a placeholder the customer can cancel away at the first write', async () => {
+    expect(JOB.status).toBe('scheduled')
+    await PATCH(req({ mileage: 125400 }), job())
+    // A cancelled online booking deletes only a job still `scheduled`.
+    expect(JOB.status).toBe('pending')
+  })
+
+  it('is not moved by reading it', async () => {
+    await GET(new Request('http://app.test/api'), job())
+    expect(JOB.status).toBe('scheduled')
+  })
+})
+
+describe('mileage and body', () => {
+  it('records the mileage on the job and raises the vehicle’s, never lowers it', async () => {
+    await PATCH(req({ mileage: 125400 }), job())
+    expect(JOB.mileage).toBe(125400)
+    expect(state.vehicle.mileage).toBe(125400)
+    await PATCH(req({ mileage: 99 }), job())
+    expect(JOB.mileage).toBe(99)
+    expect(state.vehicle.mileage).toBe(125400)
+  })
+
+  it('chooses the drawing the vehicle is marked on', async () => {
+    await PATCH(req({ bodyType: 'pickup' }), job())
+    expect(state.vehicle.bodyType).toBe('pickup')
+    expect((await PATCH(req({ bodyType: 'spaceship' }), job())).status).toBe(400)
+  })
+})
+
+describe('marks', () => {
+  it('records a tapped mark on this job’s drop-off', async () => {
+    const { status, body } = await read(
+      await addMark(
+        req({
+          bodyType: 'pickup',
+          view: 'left',
+          panel: 'left_front_door',
+          x: 0.4,
+          y: 0.6,
+          kind: 'scratch',
+        }),
+        job()
+      )
+    )
+    expect(status).toBe(201)
+    expect(state.marks[0]).toMatchObject({
+      serviceRecordId: JOB.id,
+      vehicleId: 'veh-1',
+      kind: 'scratch',
+      severity: 'minor',
+    })
+    expect(body.data.marks).toHaveLength(1)
+    // The vehicle now remembers the drawing it is marked on.
+    expect(state.vehicle.bodyType).toBe('pickup')
+  })
+
+  it('refuses a mark that is not a mark, and a kind the workshop does not have', async () => {
+    expect((await addMark(req({ view: 'left', x: 2 }), job())).status).toBe(400)
+    expect(JOB.status).toBe('scheduled')
+    const own = await addMark(
+      req({
+        bodyType: 'pickup',
+        view: 'left',
+        panel: 'left_front_door',
+        x: 0.4,
+        y: 0.6,
+        kind: 'own_nope',
+      }),
+      job()
+    )
+    expect(own.status).toBe(400)
+    // A refused mark is no arrival: the booking is left as it was.
+    expect(JOB.status).toBe('scheduled')
+  })
+
+  it('changes and removes this check-in’s marks, never an earlier visit’s', async () => {
+    state.marks = [
+      mark({ id: 'now' }),
+      mark({ id: 'before', serviceRecordId: OLD_JOB.id, recordedAt: new Date('2026-05-01') }),
+    ]
+    expect((await changeMark(req({ kind: 'chip' }), onMark('now'))).status).toBe(200)
+    expect(state.marks.find((m) => m.id === 'now')?.kind).toBe('chip')
+    expect((await changeMark(req({ kind: 'chip' }), onMark('before'))).status).toBe(404)
+    expect((await removeMark(req(), onMark('before'))).status).toBe(404)
+    expect((await removeMark(req(), onMark('now'))).status).toBe(200)
+    expect(state.marks.map((m) => m.id)).toEqual(['before'])
+  })
+})
+
+describe('earlier visits’ marks', () => {
+  it('clears one found gone, and takes the clearing back', async () => {
+    state.marks = [
+      mark({ id: 'before', serviceRecordId: OLD_JOB.id, recordedAt: new Date('2026-05-01') }),
+    ]
+    const cleared = await read(await resolve(req({ resolved: true }), onMark('before')))
+    expect(cleared.status).toBe(200)
+    expect(state.marks[0].resolvedAt).toBeInstanceOf(Date)
+    expect(cleared.body.data.marks).toEqual([])
+    expect((await resolve(req({ resolved: false }), onMark('before'))).status).toBe(200)
+    expect(state.marks[0].resolvedAt).toBeNull()
+  })
+
+  it('reopens only a mark this check-in could have cleared', async () => {
+    // Cleared on a visit opened after this job: not this job's to reopen.
+    state.marks = [
+      mark({
+        id: 'later',
+        serviceRecordId: 'job-later',
+        recordedAt: new Date('2026-11-20'),
+        resolvedAt: new Date('2026-11-21'),
+      }),
+    ]
+    const later = await resolve(req({ resolved: false }), onMark('later'))
+    expect(later.status).toBe(409)
+    expect(state.marks[0].resolvedAt).not.toBeNull()
+  })
+
+  it('tells this job’s page when an earlier mark is cleared', async () => {
+    state.marks = [
+      mark({ id: 'before', serviceRecordId: OLD_JOB.id, recordedAt: new Date('2026-05-01') }),
+    ]
+    vi.mocked(publishRecordChange).mockClear()
+    await resolve(req({ resolved: true }), onMark('before'))
+    expect(publishRecordChange).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: 'serviceRecord', id: JOB.id, hint: 'conditionMarks' })
+    )
+  })
+
+  it('does not clear a mark the desk confirmed a moment ago', async () => {
+    // Read as open, then confirmed at the desk before the clear lands.
+    const open = mark({
+      id: 'before',
+      serviceRecordId: OLD_JOB.id,
+      recordedAt: new Date('2026-05-01'),
+    })
+    state.marks = [{ ...open, resolvedAt: new Date(), carriedToId: 'now' }]
+    vi.mocked(db.conditionMark.findMany).mockResolvedValueOnce([
+      { ...open, serviceRecord: { createdAt: OLD_JOB.createdAt }, inspection: null },
+    ] as never)
+    expect((await resolve(req({ resolved: true }), onMark('before'))).status).toBe(409)
+    expect(state.marks[0].carriedToId).toBe('now')
+  })
+
+  it('does not clear this check-in’s own mark: that one is removed', async () => {
+    state.marks = [mark({ id: 'now' })]
+    expect((await resolve(req({ resolved: true }), onMark('now'))).status).toBe(400)
+  })
+
+  it('confirms every earlier mark as still there, onto this job', async () => {
+    state.marks = [
+      mark({ id: 'b1', serviceRecordId: OLD_JOB.id, recordedAt: new Date('2026-05-01T09:00:00Z') }),
+      mark({
+        id: 'b2',
+        serviceRecordId: OLD_JOB.id,
+        kind: 'rust',
+        recordedAt: new Date('2026-05-01T09:01:00Z'),
+      }),
+    ]
+    const { body } = await read(await carry(req(), job()))
+    expect(body.data.carried).toBe(2)
+    const own = body.data.marks.filter((m: { previous: boolean }) => !m.previous)
+    expect(own.map((m: { kind: string }) => m.kind)).toEqual(['dent', 'rust'])
+    expect(
+      state.marks.filter((m) => m.serviceRecordId === OLD_JOB.id).every((m) => m.carriedToId)
+    ).toBe(true)
+  })
+})
+
+describe('drop-off photos', () => {
+  const photo = async (slot: string, bytes: Uint8Array<ArrayBuffer>, type = 'image/png') => {
+    const form = new FormData()
+    form.append('slot', slot)
+    form.append('file', new File([bytes], 'shot.png', { type }))
+    return addPhoto(new Request('http://app.test/api', { method: 'POST', body: form }), job())
+  }
+  // The smallest valid PNG: one transparent pixel.
+  const PNG = Uint8Array.from(
+    atob(
+      'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
+    ),
+    (c) => c.charCodeAt(0)
+  )
+
+  it('keeps the position it was taken from, off the invoice', async () => {
+    const { status, body } = await read(await photo('front', PNG))
+    expect(status).toBe(201)
+    expect(body.data.photo.slot).toBe('front')
+    expect(state.attachments[0]).toMatchObject({
+      category: 'dropoff',
+      description: 'front',
+      includeInInvoice: false,
+    })
+  })
+
+  it('takes a close-up onto a mark, and no more than a mark holds', async () => {
+    state.marks = [mark({ id: 'now' })]
+    const form = () => {
+      const f = new FormData()
+      f.append('file', new File([PNG], 'dent.png', { type: 'image/png' }))
+      return new Request('http://app.test/api', { method: 'POST', body: f })
+    }
+    expect((await addMarkPhoto(form(), onMark('now'))).status).toBe(201)
+    expect(state.marks[0].imageUrls).toHaveLength(1)
+    state.marks[0].imageUrls = Array.from(
+      { length: 10 },
+      (_, i) => `/api/protected/files/org-1/services/p${i}.jpg`
+    )
+    expect((await addMarkPhoto(form(), onMark('now'))).status).toBe(409)
+    expect(state.marks[0].imageUrls).toHaveLength(10)
+  })
+
+  it('refuses what is not a photo', async () => {
+    expect(
+      (await photo('front', new TextEncoder().encode('%PDF-1.4'), 'application/pdf')).status
+    ).toBe(400)
+    expect((await photo('rear', new TextEncoder().encode('not a png'))).status).toBe(400)
+    expect(state.attachments).toEqual([])
+  })
+})

+ 22 - 0
src/__tests__/lib/realtime/announces-writes.test.ts

@@ -130,6 +130,28 @@ describe('a row that belongs to a record', () => {
       'clock',
     ])
   })
+  it('redraws the map on the job or inspection a damage mark is drawn on', async () => {
+    const org = async () => 'org-1'
+    // A mark changed by id names its sheet only in the row the write returns.
+    await write(
+      'ConditionMark',
+      'update',
+      { where: { id: 'm-1' }, data: { kind: 'dent' } },
+      { id: 'm-1', serviceRecordId: 'job-1', inspectionId: null },
+      org
+    )
+    await write(
+      'ConditionMark',
+      'create',
+      { data: { inspectionId: 'insp-1', serviceRecordId: null } },
+      { id: 'm-2', inspectionId: 'insp-1', serviceRecordId: null },
+      org
+    )
+    expect(published.map((p) => [(p as { kind: string }).kind, (p as { id: string }).id])).toEqual([
+      ['serviceRecord', 'job-1'],
+      ['inspection', 'insp-1'],
+    ])
+  })
   it('tells the inspection about a status report sent from it, and the job about its own', async () => {
     const org = async () => 'org-1'
     await write(

+ 112 - 0
src/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/photos/route.ts

@@ -0,0 +1,112 @@
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { discardUnsavedUpload } from '@/lib/files/manager'
+import { appendMarkPhoto, removeMarkPhoto } from '@/features/condition-map/Lib/markStore.server'
+import { checkinMarkList } from '@/features/condition-map/Lib/checkin.server'
+import {
+  answeringRefusals,
+  appLocale,
+  checkinJobFor,
+  editableMark,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+import {
+  MAX_CHECKIN_PHOTO_BYTES,
+  PHOTO_REFUSALS,
+  saveCheckinPhoto,
+} from '@/features/condition-map/Lib/checkinPhoto.server'
+
+type Params = { params: Promise<{ id: string; markId: string }> }
+
+/**
+ * A close-up of the damage a mark records, from the technician app. One
+ * photo a request, as the camera takes them; the web's mark editor shows it
+ * at once and the print sets it under the map.
+ */
+
+/** As many as a mark on the web can take. */
+const PHOTOS_PER_MARK = 10
+
+const UPDATE_SERVICES = [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }]
+
+export async function POST(request: Request, { params }: Params) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, markId } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const mark = await editableMark(ctx.organizationId, job, markId)
+      if (!mark) return apiError(404, 'not_found', 'That mark is not on this check-in.')
+      if (mark.imageUrls.length >= PHOTOS_PER_MARK) {
+        return apiError(409, 'conflict', `A mark can have at most ${PHOTOS_PER_MARK} photos.`)
+      }
+
+      try {
+        assertContentLength(request, MAX_CHECKIN_PHOTO_BYTES + 1024 * 1024)
+      } catch {
+        return apiError(413, 'invalid_request', PHOTO_REFUSALS.tooLarge)
+      }
+      const form = await request.formData().catch(() => null)
+      const saved = await saveCheckinPhoto(ctx.organizationId, form?.get('file') ?? null)
+      if ('refused' in saved) return apiError(400, 'invalid_request', PHOTO_REFUSALS[saved.refused])
+
+      return answeringRefusals(async () => {
+        // The photo is only kept if the mark holds it.
+        let added: Awaited<ReturnType<typeof appendMarkPhoto>>
+        try {
+          added = await appendMarkPhoto(ctx.organizationId, markId, saved.url, PHOTOS_PER_MARK)
+        } catch (err) {
+          await discardUnsavedUpload(ctx.organizationId, 'services', saved.filename)
+          throw err
+        }
+        if (!added) {
+          await discardUnsavedUpload(ctx.organizationId, 'services', saved.filename)
+          return apiError(409, 'conflict', `A mark can have at most ${PHOTOS_PER_MARK} photos.`)
+        }
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ url: saved.url, marks }, 201)
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: UPDATE_SERVICES,
+      rateLimit: { limit: 60, windowMs: 60_000 },
+    }
+  )
+}
+
+/** `?url=` names the photo to take off the mark. */
+export async function DELETE(request: Request, { params }: Params) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, markId } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const mark = await editableMark(ctx.organizationId, job, markId)
+      if (!mark) return apiError(404, 'not_found', 'That mark is not on this check-in.')
+      const url = new URL(request.url).searchParams.get('url') ?? ''
+      if (!mark.imageUrls.includes(url)) {
+        return apiError(404, 'not_found', 'That photo is not on this mark.')
+      }
+
+      return answeringRefusals(async () => {
+        await removeMarkPhoto(ctx.organizationId, markId, url)
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ marks })
+      })
+    },
+    { requireTechnician: true, requiredPermissions: UPDATE_SERVICES }
+  )
+}

+ 97 - 0
src/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/resolve/route.ts

@@ -0,0 +1,97 @@
+import { z } from 'zod'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { splitMarks } from '@/features/condition-map/Lib/marks'
+import { loadVehicleConditionMarks } from '@/features/condition-map/Lib/loadMarks.server'
+import { setMarkResolvedIfUnchanged } from '@/features/condition-map/Lib/markStore.server'
+import { checkinMarkList } from '@/features/condition-map/Lib/checkin.server'
+import {
+  answeringRefusals,
+  announceMarksChanged,
+  appLocale,
+  checkinJobFor,
+  noteArrival,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+
+/**
+ * A mark from an earlier visit that the technician walking round the car
+ * finds gone: repaired, or never there. It stays as that visit's history and
+ * stops showing as current. `{ resolved: false }` takes it back, for the undo
+ * straight after.
+ *
+ * On the web this is the vehicle's right. Here it is part of checking in a
+ * job the technician is on, so the job's right covers it, for marks on that
+ * job's own vehicle only.
+ */
+const bodySchema = z.object({ resolved: z.boolean() })
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string; markId: string }> }
+) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, markId } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const parsed = bodySchema.safeParse(await request.json().catch(() => null))
+      if (!parsed.success) return apiError(400, 'invalid_request', 'Say whether it is resolved.')
+      const { resolved } = parsed.data
+
+      const all = await loadVehicleConditionMarks(ctx.organizationId, job.vehicleId)
+      const mark = all.find((m) => m.id === markId)
+      if (!mark) return apiError(404, 'not_found', 'That mark is not on this vehicle.')
+      if (mark.serviceRecordId === job.id) {
+        return apiError(400, 'invalid_request', 'A mark of this check-in is removed, not cleared.')
+      }
+      if (resolved) {
+        const { previous } = splitMarks(all, job.scope)
+        if (!previous.some((m) => m.id === markId)) {
+          return apiError(409, 'conflict', 'That mark is already closed.')
+        }
+      } else {
+        if (!mark.resolvedAt || mark.carriedToId) {
+          return apiError(409, 'conflict', 'That mark is not cleared.')
+        }
+        // Only a mark this check-in could have cleared: one that, open again,
+        // would be an earlier visit's mark on this job's map. A later visit's
+        // or the linked inspection's is not this job's to reopen.
+        const reopened = all.map((m) => (m.id === markId ? { ...m, resolvedAt: null } : m))
+        if (!splitMarks(reopened, job.scope).previous.some((m) => m.id === markId)) {
+          return apiError(409, 'conflict', "That mark is not this check-in's to reopen.")
+        }
+      }
+
+      return answeringRefusals(async () => {
+        // Written only in the state it was checked in: the desk may have
+        // confirmed or cleared the same mark a moment ago.
+        const changed = await setMarkResolvedIfUnchanged(
+          ctx.organizationId,
+          ctx.userId,
+          markId,
+          resolved
+        )
+        if (!changed) {
+          return apiError(409, 'conflict', 'That mark was changed meanwhile. Pull to refresh.')
+        }
+        await noteArrival(ctx.organizationId, job)
+        // The write names the earlier visit's job; this one's page is told too.
+        announceMarksChanged(ctx.organizationId, job.id)
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ marks })
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 83 - 0
src/app/api/v1/tech/jobs/[id]/checkin/marks/[markId]/route.ts

@@ -0,0 +1,83 @@
+import { logAudit } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { markPatchSchema } from '@/features/condition-map/Lib/marks'
+import { deleteMark, patchMark } from '@/features/condition-map/Lib/markStore.server'
+import { checkinMarkList } from '@/features/condition-map/Lib/checkin.server'
+import {
+  answeringRefusals,
+  appLocale,
+  checkinJobFor,
+  editableMark,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+
+type Params = { params: Promise<{ id: string; markId: string }> }
+
+/**
+ * Change or remove a mark this job's check-in drew. A mark from an earlier
+ * visit is that visit's record and is not changed from here: it is confirmed
+ * as still there (carry) or cleared as repaired (resolve).
+ */
+
+const UPDATE_SERVICES = [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }]
+
+export async function PATCH(request: Request, { params }: Params) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, markId } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      if (!(await editableMark(ctx.organizationId, job, markId))) {
+        return apiError(404, 'not_found', 'That mark is not on this check-in.')
+      }
+      const parsed = markPatchSchema.safeParse(await request.json().catch(() => null))
+      if (!parsed.success) return apiError(400, 'invalid_request', 'That change is not valid.')
+
+      return answeringRefusals(async () => {
+        await patchMark(ctx.organizationId, markId, parsed.data)
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ marks })
+      })
+    },
+    { requireTechnician: true, requiredPermissions: UPDATE_SERVICES }
+  )
+}
+
+export async function DELETE(request: Request, { params }: Params) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id, markId } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      if (!(await editableMark(ctx.organizationId, job, markId))) {
+        return apiError(404, 'not_found', 'That mark is not on this check-in.')
+      }
+
+      return answeringRefusals(async () => {
+        const mark = await deleteMark(ctx.organizationId, markId)
+        await logAudit(ctx, {
+          action: 'conditionMark.remove',
+          entity: 'ConditionMark',
+          entityId: mark.id,
+          details: { key: 'condition_mark_remove' },
+          metadata: { vehicleId: mark.vehicleId, serviceRecordId: job.id, via: 'tech-checkin' },
+        })
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ marks })
+      })
+    },
+    { requireTechnician: true, requiredPermissions: UPDATE_SERVICES }
+  )
+}

+ 60 - 0
src/app/api/v1/tech/jobs/[id]/checkin/marks/carry/route.ts

@@ -0,0 +1,60 @@
+import { logAudit } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { carryMarks } from '@/features/condition-map/Lib/markStore.server'
+import { checkinMarkList } from '@/features/condition-map/Lib/checkin.server'
+import {
+  answeringRefusals,
+  appLocale,
+  checkinJobFor,
+  noteArrival,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+
+/**
+ * Every mark still open from an earlier visit, confirmed as still there:
+ * each is recorded again on this job's check-in and the earlier one closed,
+ * as the web's "Still there" does. Safe to send twice.
+ *
+ * The job's right covers it here, as with clearing a mark: the technician
+ * is checking in a job they are on, and only that job's vehicle is touched.
+ */
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      return answeringRefusals(async () => {
+        await noteArrival(ctx.organizationId, job)
+        const { marks: created } = await carryMarks(ctx.organizationId, ctx.userId, {
+          vehicleId: job.vehicleId,
+          serviceRecordId: job.id,
+        })
+        if (created.length > 0) {
+          await logAudit(ctx, {
+            action: 'conditionMark.carry',
+            entity: 'Vehicle',
+            entityId: job.vehicleId,
+            details: { key: 'condition_mark_carry', params: { count: created.length } },
+            metadata: { vehicleId: job.vehicleId, serviceRecordId: job.id, via: 'tech-checkin' },
+          })
+        }
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ carried: created.length, marks })
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 70 - 0
src/app/api/v1/tech/jobs/[id]/checkin/marks/route.ts

@@ -0,0 +1,70 @@
+import { logAudit } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { markInputSchema } from '@/features/condition-map/Lib/marks'
+import { assertKnownKind, createMark } from '@/features/condition-map/Lib/markStore.server'
+import { checkinMarkList } from '@/features/condition-map/Lib/checkin.server'
+import {
+  answeringRefusals,
+  appLocale,
+  checkinJobFor,
+  noteArrival,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+
+/**
+ * A mark drawn on the job's condition map from the technician app: where the
+ * technician tapped (a panel of a view, as fractions of the view's box), what
+ * kind of damage, how bad, and a note. Recorded on the job's drop-off, as the
+ * web's Drop-off tab records one, under the same rules.
+ *
+ * Answers with the job's marks renumbered, so the app shows the numbers the
+ * desk and the print show.
+ */
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const body = await request.json().catch(() => null)
+      const parsed = markInputSchema.safeParse({
+        ...(body && typeof body === 'object' ? body : {}),
+        vehicleId: job.vehicleId,
+        serviceRecordId: job.id,
+        inspectionId: null,
+        inspectionItemId: null,
+      })
+      if (!parsed.success) return apiError(400, 'invalid_request', 'That mark is not complete.')
+
+      return answeringRefusals(async () => {
+        // Refused before the booking is touched: a mark that will not be
+        // saved is no arrival.
+        await assertKnownKind(ctx.organizationId, parsed.data.kind)
+        await noteArrival(ctx.organizationId, job)
+        const mark = await createMark(ctx.organizationId, ctx.userId, parsed.data)
+        await logAudit(ctx, {
+          action: 'conditionMark.add',
+          entity: 'ConditionMark',
+          entityId: mark.id,
+          details: { key: 'condition_mark_add', params: { kind: mark.kind, panel: mark.panel } },
+          metadata: { vehicleId: mark.vehicleId, serviceRecordId: job.id, via: 'tech-checkin' },
+        })
+        const marks = await checkinMarkList(
+          ctx.organizationId,
+          job.vehicleId,
+          job.scope,
+          await appLocale(request, ctx.organizationId)
+        )
+        return apiOk({ id: mark.id, marks }, 201)
+      })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      rateLimit: { limit: 60, windowMs: 60_000 },
+    }
+  )
+}

+ 111 - 0
src/app/api/v1/tech/jobs/[id]/checkin/photos/route.ts

@@ -0,0 +1,111 @@
+import { db } from '@/lib/db'
+import { logAudit } from '@/lib/audit'
+import { discardUnsavedUpload } from '@/lib/files/manager'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { assertContentLength } from '@/lib/backup/zip-guard'
+import { isDropoffSlot } from '@/lib/dropoff-slots'
+import { checkinJobFor, noteArrival } from '@/features/condition-map/Lib/checkinAccess.server'
+import {
+  MAX_CHECKIN_PHOTO_BYTES,
+  PHOTO_REFUSALS,
+  saveCheckinPhoto,
+} from '@/features/condition-map/Lib/checkinPhoto.server'
+
+/**
+ * A drop-off photo from the walk round the car: front, right, rear, left, the
+ * odometer, or damage. Stored as the web's photo walk stores one, so it shows
+ * under Files & media → Drop-off with the position it was taken from.
+ *
+ * Not on the invoice: it is the workshop's record of the car as it arrived,
+ * for the day somebody says a scratch is new. The desk can still choose to
+ * show one. A retake is a DELETE of the old one through the attachments
+ * route, then a new one here.
+ */
+
+/** As many as the phone walk allows on one job. */
+const DROPOFF_PER_JOB = 30
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+
+      try {
+        assertContentLength(request, MAX_CHECKIN_PHOTO_BYTES + 1024 * 1024)
+      } catch {
+        return apiError(413, 'invalid_request', PHOTO_REFUSALS.tooLarge)
+      }
+      const form = await request.formData().catch(() => null)
+      const slotField = form?.get('slot')
+      const slot = isDropoffSlot(slotField) ? slotField : 'other'
+
+      const onJob = await db.serviceAttachment.count({
+        where: { serviceRecordId: job.id, category: 'dropoff' },
+      })
+      if (onJob >= DROPOFF_PER_JOB) {
+        return apiError(
+          409,
+          'conflict',
+          `This job already has the maximum of ${DROPOFF_PER_JOB} drop-off photos.`
+        )
+      }
+
+      const saved = await saveCheckinPhoto(ctx.organizationId, form?.get('file') ?? null)
+      if ('refused' in saved) return apiError(400, 'invalid_request', PHOTO_REFUSALS[saved.refused])
+      await noteArrival(ctx.organizationId, job)
+
+      // The file is only kept if its row is: a file on disk that no attachment
+      // points at is one nothing will ever delete.
+      const attachment = await db.serviceAttachment
+        .create({
+          data: {
+            fileName: saved.fileName,
+            fileUrl: saved.url,
+            fileType: 'image/jpeg',
+            fileSize: saved.size,
+            category: 'dropoff',
+            includeInInvoice: false,
+            description: slot,
+            serviceRecordId: job.id,
+          },
+          select: { id: true, description: true, fileUrl: true, createdAt: true },
+        })
+        .catch(async (err) => {
+          await discardUnsavedUpload(ctx.organizationId, 'services', saved.filename)
+          throw err
+        })
+
+      await logAudit(ctx, {
+        action: 'service.update',
+        entity: 'ServiceRecord',
+        entityId: job.id,
+        details: { key: 'service_update', params: { ref: job.ref } },
+        metadata: { serviceRecordId: job.id, attachmentId: attachment.id, via: 'tech-checkin' },
+      })
+
+      return apiOk(
+        {
+          photo: {
+            id: attachment.id,
+            slot,
+            url: attachment.fileUrl,
+            createdAt: attachment.createdAt.toISOString(),
+          },
+        },
+        201
+      )
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+      // A walk is six shots and a few of damage, sent as fast as they are taken.
+      rateLimit: { limit: 60, windowMs: 60_000 },
+    }
+  )
+}

+ 109 - 0
src/app/api/v1/tech/jobs/[id]/checkin/route.ts

@@ -0,0 +1,109 @@
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { logAudit } from '@/lib/audit'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { BODY_TYPES } from '@/features/condition-map/Lib/drawingTypes'
+import { loadCheckin } from '@/features/condition-map/Lib/checkin.server'
+import {
+  appLocale,
+  checkinJobFor,
+  noteArrival,
+} from '@/features/condition-map/Lib/checkinAccess.server'
+
+/**
+ * A job's check-in, for the technician walking round the car as it arrives:
+ * the mileage, the drop-off photos by position and the condition map. The
+ * same records the web's Drop-off tab shows, so the desk sees the walk as it
+ * happens and it prints the same way.
+ *
+ * `?body=` draws another body type for a look before choosing it; the
+ * vehicle keeps its own until a PATCH says otherwise.
+ */
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const body = new URL(request.url).searchParams.get('body')
+      const checkin = await loadCheckin(
+        ctx.organizationId,
+        job.id,
+        await appLocale(request, ctx.organizationId),
+        { body }
+      )
+      if (!checkin) return apiError(404, 'not_found', 'That job has no vehicle to check in.')
+      return apiOk(checkin)
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }],
+    }
+  )
+}
+
+const patchSchema = z
+  .object({
+    /** On the dashboard as the car arrived; null clears a wrong one. */
+    mileage: z.number().int().min(0).max(10_000_000).nullable().optional(),
+    /** The drawing the vehicle is marked on. */
+    bodyType: z.enum(BODY_TYPES).optional(),
+  })
+  .refine((v) => v.mileage !== undefined || v.bodyType !== undefined, 'Nothing to change')
+
+export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
+  return withApiAuth(
+    request,
+    async (ctx) => {
+      const { id } = await params
+      const job = await checkinJobFor(ctx, id)
+      if (!job) return apiError(404, 'not_found', 'That job is not on your list.')
+      const parsed = patchSchema.safeParse(await request.json().catch(() => null))
+      if (!parsed.success) return apiError(400, 'invalid_request', 'Send a mileage or a body type.')
+      const { mileage, bodyType } = parsed.data
+      await noteArrival(ctx.organizationId, job)
+
+      if (mileage !== undefined) {
+        await db.serviceRecord.updateMany({
+          where: { id: job.id, organizationId: ctx.organizationId },
+          data: { mileage },
+        })
+        // The vehicle's reading only ever goes up, as it does when the job is
+        // saved on the web: a typo on an old job must not wind the clock back.
+        if (mileage !== null) {
+          await db.vehicle.updateMany({
+            where: {
+              id: job.vehicleId,
+              organizationId: ctx.organizationId,
+              mileage: { lt: mileage },
+            },
+            data: { mileage },
+          })
+        }
+      }
+      if (bodyType) {
+        await db.vehicle.updateMany({
+          where: { id: job.vehicleId, organizationId: ctx.organizationId },
+          data: { bodyType },
+        })
+      }
+
+      await logAudit(ctx, {
+        action: 'service.update',
+        entity: 'ServiceRecord',
+        entityId: job.id,
+        details: { key: 'service_update', params: { ref: job.ref } },
+        metadata: { serviceRecordId: job.id, via: 'tech-checkin', mileage, bodyType },
+      })
+      return apiOk({ mileage: mileage ?? undefined, bodyType: bodyType ?? undefined })
+    },
+    {
+      requireTechnician: true,
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
+      ],
+    }
+  )
+}

+ 12 - 1
src/app/api/v1/tech/jobs/route.ts

@@ -2,6 +2,8 @@ import { db } from '@/lib/db'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { getOpenEntry } from '@/features/time-tracking/Lib/timeEntries'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { addZonedDays, startOfZonedDay } from '@/lib/timezone'
 
 /** Statuses that mean "this is on my plate right now". */
 const ACTIVE_STATUSES = ['in-progress', 'pending', 'waiting-parts']
@@ -18,11 +20,20 @@ export async function GET(request: Request) {
   return withApiAuth(
     request,
     async (ctx) => {
+      // A booking (an online one, or one the desk made ahead) is `scheduled`
+      // until work starts. From its day on it is the car arriving, the one a
+      // technician checks in, so it is on the list; a booking for next week
+      // is not yet.
+      const timeZone = await workshopTimeZone(ctx.organizationId)
+      const endOfToday = addZonedDays(startOfZonedDay(new Date(), timeZone), 1, timeZone)
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId: ctx.organizationId,
-          status: { in: ACTIVE_STATUSES },
           technicianId: { in: ctx.technicianIds },
+          OR: [
+            { status: { in: ACTIVE_STATUSES } },
+            { status: 'scheduled', startDateTime: { lt: endOfToday } },
+          ],
         },
         select: {
           id: true,

+ 31 - 232
src/features/condition-map/Actions/conditionMarkActions.ts

@@ -11,13 +11,21 @@ import { releaseFiles } from '@/lib/files/manager'
 import { BODY_TYPES } from '../Lib/drawingTypes'
 import {
   type ConditionMarkData,
-  type MarkScope,
   markInputSchema,
   markPatchSchema,
   numberedMarks,
-  splitMarks,
 } from '../Lib/marks'
-import { loadVehicleConditionMarks, MARK_SELECT } from '../Lib/loadMarks.server'
+import { MARK_SELECT } from '../Lib/loadMarks.server'
+import {
+  addMarkPhotos,
+  carryMarks,
+  createMark,
+  deleteMark,
+  markPaths,
+  patchMark,
+  removeMarkPhoto,
+  setMarkResolved,
+} from '../Lib/markStore.server'
 
 /**
  * The marks on a vehicle's condition map.
@@ -50,110 +58,14 @@ export async function listVehicleConditionMarks(vehicleId: string) {
   )
 }
 
-/**
- * Checks that the sheet a mark is drawn on is this workshop's, is this
- * vehicle's, and is still open: a completed inspection and an issued
- * invoice keep their marks as they were. Answers with the visit the sheet
- * records, for the one action that has to tell its marks from earlier ones.
- */
-async function assertSheetOpen(
-  organizationId: string,
-  scope: {
-    vehicleId: string
-    inspectionId?: string | null
-    inspectionItemId?: string | null
-    serviceRecordId?: string | null
-  }
-): Promise<MarkScope> {
-  if (scope.inspectionId) {
-    const inspection = await db.inspection.findFirst({
-      where: { id: scope.inspectionId, organizationId, vehicleId: scope.vehicleId },
-      select: {
-        status: true,
-        createdAt: true,
-        items: { where: { id: scope.inspectionItemId ?? '' }, select: { id: true } },
-      },
-    })
-    if (!inspection) throw new Error('Inspection not found')
-    if (inspection.status === 'completed')
-      throw new Error('Reopen the inspection to change its condition map')
-    if (scope.inspectionItemId && inspection.items.length === 0) throw new Error('Check not found')
-    return {
-      inspectionId: scope.inspectionId,
-      inspectionItemId: scope.inspectionItemId ?? '',
-      openedAt: inspection.createdAt,
-    }
-  }
-  if (scope.serviceRecordId) {
-    const job = await db.serviceRecord.findFirst({
-      where: { id: scope.serviceRecordId, organizationId, vehicleId: scope.vehicleId },
-      select: { id: true, inspectionId: true, createdAt: true },
-    })
-    if (!job) throw new Error('Work order not found')
-    return {
-      serviceRecordId: job.id,
-      linkedInspectionId: job.inspectionId,
-      openedAt: job.createdAt,
-    }
-  }
-  throw new Error('A mark needs a sheet to be drawn on')
-}
-
-function pathsFor(scope: {
-  vehicleId: string
-  inspectionId?: string | null
-  serviceRecordId?: string | null
-}) {
-  const paths = [`/vehicles/${scope.vehicleId}`]
-  if (scope.inspectionId) paths.push(`/inspections/${scope.inspectionId}`)
-  if (scope.serviceRecordId)
-    paths.push(`/vehicles/${scope.vehicleId}/service/${scope.serviceRecordId}`)
-  return paths
-}
-
-/**
- * A kind of the workshop's own has to be one of its own. A built-in key is
- * always known; the schema has already refused anything else.
- */
-async function assertKnownKind(organizationId: string, kind: string | undefined) {
-  if (!kind || !kind.startsWith('own_')) return
-  const known = await db.conditionMarkType.count({ where: { organizationId, key: kind } })
-  if (!known) throw new Error('Unknown kind of mark')
-}
-
 export async function addConditionMark(input: unknown) {
   const result = markInputSchema.safeParse(input)
   if (!result.success) return { success: false as const, error: 'Invalid mark' }
   const parsed = result.data
   return withAuth(
     async ({ organizationId, userId }): Promise<ConditionMarkData> => {
-      await assertSheetOpen(organizationId, parsed)
-      await assertKnownKind(organizationId, parsed.kind)
-      const mark = await db.conditionMark.create({
-        data: {
-          organizationId,
-          vehicleId: parsed.vehicleId,
-          inspectionId: parsed.inspectionId ?? null,
-          inspectionItemId: parsed.inspectionItemId ?? null,
-          serviceRecordId: parsed.serviceRecordId ?? null,
-          bodyType: parsed.bodyType,
-          view: parsed.view,
-          panel: parsed.panel,
-          x: parsed.x,
-          y: parsed.y,
-          kind: parsed.kind,
-          severity: parsed.severity,
-          note: parsed.note?.trim() || null,
-          recordedById: userId,
-        },
-        select: MARK_SELECT,
-      })
-      // The vehicle remembers which drawing it is marked on.
-      await db.vehicle.updateMany({
-        where: { id: parsed.vehicleId, organizationId, bodyType: null },
-        data: { bodyType: parsed.bodyType },
-      })
-      for (const path of pathsFor(parsed)) revalidatePath(path)
+      const mark = await createMark(organizationId, userId, parsed)
+      for (const path of markPaths(parsed)) revalidatePath(path)
       return mark
     },
     {
@@ -169,39 +81,15 @@ export async function addConditionMark(input: unknown) {
   )
 }
 
-async function ownMark(organizationId: string, id: string) {
-  const mark = await db.conditionMark.findFirst({
-    where: { id, organizationId },
-    select: MARK_SELECT,
-  })
-  if (!mark) throw new Error('Mark not found')
-  return mark
-}
-
 export async function updateConditionMark(id: string, input: unknown) {
   const result = markPatchSchema.safeParse(input)
   if (!result.success) return { success: false as const, error: 'Invalid mark' }
   const patch = result.data
   return withAuth(
     async ({ organizationId }): Promise<ConditionMarkData> => {
-      const mark = await ownMark(organizationId, id)
-      await assertSheetOpen(organizationId, mark)
-      await assertKnownKind(organizationId, patch.kind)
-      const updated = await db.conditionMark.update({
-        where: { id },
-        data: {
-          ...(patch.kind ? { kind: patch.kind } : {}),
-          ...(patch.severity ? { severity: patch.severity } : {}),
-          ...(patch.note !== undefined ? { note: patch.note?.trim() || null } : {}),
-          ...(patch.x !== undefined ? { x: patch.x } : {}),
-          ...(patch.y !== undefined ? { y: patch.y } : {}),
-          ...(patch.view ? { view: patch.view } : {}),
-          ...(patch.panel ? { panel: patch.panel } : {}),
-        },
-        select: MARK_SELECT,
-      })
-      for (const path of pathsFor(mark)) revalidatePath(path)
-      return updated
+      const { before, after } = await patchMark(organizationId, id, patch)
+      for (const path of markPaths(before)) revalidatePath(path)
+      return after
     },
     {
       requiredPermissions: [
@@ -214,13 +102,8 @@ export async function updateConditionMark(id: string, input: unknown) {
 export async function removeConditionMark(id: string) {
   return withAuth(
     async ({ organizationId }) => {
-      const mark = await ownMark(organizationId, id)
-      await assertSheetOpen(organizationId, mark)
-      await db.conditionMark.delete({ where: { id } })
-      // Its photos go with it, through the file manager like every delete:
-      // to the trash, unless some other row still uses the same file.
-      await releaseFiles(mark.imageUrls, { organizationId, reason: 'condition mark removed' })
-      for (const path of pathsFor(mark)) revalidatePath(path)
+      const mark = await deleteMark(organizationId, id)
+      for (const path of markPaths(mark)) revalidatePath(path)
       return { id, vehicleId: mark.vehicleId }
     },
     {
@@ -246,18 +129,10 @@ export async function removeConditionMark(id: string) {
 export async function resolveConditionMark(id: string, resolved: boolean) {
   return withAuth(
     async ({ organizationId, userId }): Promise<ConditionMarkData> => {
-      const mark = await ownMark(organizationId, id)
-      const updated = await db.conditionMark.update({
-        where: { id },
-        data: resolved
-          ? { resolvedAt: new Date(), resolvedById: userId }
-          : { resolvedAt: null, resolvedById: null },
-        select: MARK_SELECT,
-      })
+      const { before, after } = await setMarkResolved(organizationId, userId, id, resolved)
       // The vehicle page and the sheet the mark was drawn on both show it.
-      revalidatePath(`/vehicles/${mark.vehicleId}`)
-      for (const path of pathsFor(mark)) revalidatePath(path)
-      return updated
+      for (const path of markPaths(before)) revalidatePath(path)
+      return after
     },
     {
       requiredPermissions: [
@@ -281,21 +156,7 @@ const carrySchema = z
       : Boolean(sheet.inspectionId && sheet.inspectionItemId)
   )
 
-/**
- * Every mark still open from an earlier visit, confirmed as still there in
- * one go: each is recorded again on this sheet as this visit's own, and the
- * earlier one is closed in the same transaction, so the vehicle never carries
- * the same dent twice and the next visit meets it once.
- *
- * The earlier mark is kept rather than moved, because it is the earlier
- * visit's record: it points at the newer mark, so its own sheet goes on
- * showing it while every other sheet meets the newer one. The new mark
- * keeps the old one's photos by pointing at the same files; the file manager
- * counts both rows, so removing a photo from one leaves the other's in place.
- *
- * Safe to ask twice, or by two people at once: an earlier mark is taken only
- * while it is still open, and whoever comes second finds none left to take.
- */
+/** Every mark still open from an earlier visit, confirmed as still there; see carryMarks. */
 export async function carryConditionMarks(input: unknown) {
   const result = carrySchema.safeParse(input)
   if (!result.success) return { success: false as const, error: 'Invalid sheet' }
@@ -305,61 +166,10 @@ export async function carryConditionMarks(input: unknown) {
       organizationId,
       userId,
     }): Promise<{ vehicleId: string; marks: ConditionMarkData[]; carried: string[] }> => {
-      const scope = await assertSheetOpen(organizationId, sheet)
-      const all = await loadVehicleConditionMarks(organizationId, sheet.vehicleId)
-      const earlier = splitMarks(all, scope).previous
-      if (earlier.length === 0) return { vehicleId: sheet.vehicleId, marks: [], carried: [] }
-
-      const now = new Date()
-      const carried: ConditionMarkData[] = []
-      const marks = await db.$transaction(async (tx) => {
-        const created: ConditionMarkData[] = []
-        for (const [index, mark] of earlier.entries()) {
-          // Closing it is what claims it. Somebody else who carried or
-          // cleared it a moment ago has closed it already, and it is left.
-          const claimed = await tx.conditionMark.updateMany({
-            where: { id: mark.id, organizationId, resolvedAt: null },
-            data: { resolvedAt: now, resolvedById: userId },
-          })
-          if (claimed.count === 0) continue
-          carried.push(mark)
-          const next = await tx.conditionMark.create({
-            data: {
-              organizationId,
-              vehicleId: mark.vehicleId,
-              inspectionId: sheet.inspectionId ?? null,
-              inspectionItemId: sheet.inspectionItemId ?? null,
-              serviceRecordId: sheet.serviceRecordId ?? null,
-              bodyType: mark.bodyType,
-              view: mark.view,
-              panel: mark.panel,
-              x: mark.x,
-              y: mark.y,
-              kind: mark.kind,
-              severity: mark.severity,
-              note: mark.note,
-              imageUrls: mark.imageUrls,
-              // Marks are numbered by when they were recorded, so each gets
-              // a moment of its own and they keep the order they had.
-              recordedAt: new Date(now.getTime() + index),
-              recordedById: userId,
-            },
-            select: MARK_SELECT,
-          })
-          // Closed because it was superseded, not repaired: the earlier
-          // sheet still prints it as its own record.
-          await tx.conditionMark.update({
-            where: { id: mark.id },
-            data: { carriedToId: next.id },
-          })
-          created.push(next)
-        }
-        return created
-      })
-
+      const { marks, carried } = await carryMarks(organizationId, userId, sheet)
       // This sheet, and every sheet a mark was carried from.
-      const paths = new Set(pathsFor(sheet))
-      for (const mark of carried) for (const path of pathsFor(mark)) paths.add(path)
+      const paths = new Set(markPaths(sheet))
+      for (const mark of carried) for (const path of markPaths(mark)) paths.add(path)
       for (const path of paths) revalidatePath(path)
       return { vehicleId: sheet.vehicleId, marks, carried: carried.map((mark) => mark.id) }
     },
@@ -392,13 +202,9 @@ export async function addConditionMarkPhotos(input: unknown) {
   const { id, urls } = result.data
   return withAuth(
     async ({ organizationId }): Promise<string[]> => {
-      assertOwnUploads(urls, organizationId)
-      const mark = await ownMark(organizationId, id)
-      await assertSheetOpen(organizationId, mark)
-      const next = [...mark.imageUrls, ...urls.filter((u) => !mark.imageUrls.includes(u))]
-      await db.conditionMark.update({ where: { id }, data: { imageUrls: next } })
-      for (const path of pathsFor(mark)) revalidatePath(path)
-      return next
+      const { mark, imageUrls } = await addMarkPhotos(organizationId, id, urls)
+      for (const path of markPaths(mark)) revalidatePath(path)
+      return imageUrls
     },
     {
       requiredPermissions: [
@@ -414,16 +220,9 @@ export async function removeConditionMarkPhoto(input: unknown) {
   const { id, url } = parsed.data
   return withAuth(
     async ({ organizationId }): Promise<string[]> => {
-      const mark = await ownMark(organizationId, id)
-      await assertSheetOpen(organizationId, mark)
-      const next = mark.imageUrls.filter((u) => u !== url)
-      await db.conditionMark.update({ where: { id }, data: { imageUrls: next } })
-      // Only a photo this mark had: a URL it never held is not ours to release.
-      if (next.length !== mark.imageUrls.length) {
-        await releaseFiles([url], { organizationId, reason: 'condition mark photo removed' })
-      }
-      for (const path of pathsFor(mark)) revalidatePath(path)
-      return next
+      const { mark, imageUrls } = await removeMarkPhoto(organizationId, id, url)
+      for (const path of markPaths(mark)) revalidatePath(path)
+      return imageUrls
     },
     {
       requiredPermissions: [

+ 254 - 0
src/features/condition-map/Lib/checkin.server.ts

@@ -0,0 +1,254 @@
+import 'server-only'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { DROPOFF_SLOTS, isDropoffSlot } from '@/lib/dropoff-slots'
+import { getBodyDrawing } from '../Drawings'
+import { composeViews, placedLines, placedPanels } from './compose'
+import { BODY_TYPES, type BodyType, VIEW_SIZE } from './drawingTypes'
+import { type ConditionMapMessages, loadConditionMapMessages } from './labels'
+import { loadVehicleConditionMarks, markTypeCatalogue } from './loadMarks.server'
+import {
+  type ConditionMarkData,
+  type MarkScope,
+  bodyTypeFor,
+  isBodyType,
+  isDrawnOnSheet,
+  numberedMarks,
+  splitMarks,
+} from './marks'
+
+/**
+ * A work order's check-in as the technician app draws it: the mileage, the
+ * drop-off photos by position, and the condition map with every mark the
+ * web's Drop-off tab shows, numbered the same way.
+ *
+ * The drawing goes over the wire as placed paths rather than being bundled
+ * into the app, so a new body type or a redrawn panel reaches phones with
+ * the next web deploy, not the next store release.
+ */
+
+export interface CheckinMark {
+  id: string
+  number: number
+  bodyType: string
+  view: string
+  panel: string
+  panelName: string
+  x: number
+  y: number
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  /** Still open from an earlier visit: drawn grey, confirmed or cleared. */
+  previous: boolean
+  /** Drawn on this job, so the app may move, change or remove it. */
+  editable: boolean
+  recordedAt: string
+}
+
+export interface CheckinSheet {
+  body: BodyType
+  /** The size of the whole sheet in drawing units; each view sits in its box. */
+  width: number
+  height: number
+  /** How a view's 0..VIEW_SIZE box maps onto the sheet: [a, b, c, d, e, f]. */
+  viewSize: number
+  views: {
+    view: string
+    label: string
+    box: { x: number; y: number; width: number; height: number }
+    transform: [number, number, number, number, number, number]
+  }[]
+  panels: { view: string; panel: string; name: string; d: string }[]
+  lines: string[]
+}
+
+export interface Checkin {
+  job: { id: string; mileage: number | null; status: string }
+  vehicle: {
+    id: string
+    make: string
+    model: string
+    year: number | null
+    licensePlate: string | null
+    mileage: number
+    /** The body chosen for the vehicle, or null when the drawing is a guess. */
+    bodyType: string | null
+  }
+  /** automotive or marine: says whether mileage is kilometres or engine hours. */
+  serviceType: string
+  bodies: { key: string; name: string }[]
+  sheet: CheckinSheet
+  /** Every kind, hidden ones too: a mark may carry one. The picker leaves those out. */
+  kinds: { key: string; name: string; shape: string; color: string; hidden: boolean }[]
+  severities: { key: string; name: string }[]
+  marks: CheckinMark[]
+  photos: { id: string; slot: string; url: string; createdAt: string }[]
+  slots: readonly string[]
+}
+
+/** The drawing for a body, placed on the sheet, named in a language's words. */
+export function checkinSheet(body: BodyType, messages: ConditionMapMessages): CheckinSheet {
+  const drawing = getBodyDrawing(body)
+  const composition = composeViews()
+  return {
+    body,
+    width: composition.width,
+    height: composition.height,
+    viewSize: VIEW_SIZE,
+    views: composition.views.map((placed) => ({
+      view: placed.view,
+      label: messages.views[placed.view] ?? placed.view,
+      box: placed.box,
+      transform: [
+        placed.transform.a,
+        placed.transform.b,
+        placed.transform.c,
+        placed.transform.d,
+        placed.transform.e,
+        placed.transform.f,
+      ],
+    })),
+    panels: placedPanels(drawing, composition).map((panel) => ({
+      view: panel.view,
+      panel: panel.panel,
+      name: messages.panels[panel.panel] ?? panel.panel,
+      d: panel.d,
+    })),
+    lines: placedLines(drawing, composition),
+  }
+}
+
+/** The job's visit, for telling its marks from earlier ones. */
+export function jobScope(job: {
+  id: string
+  inspectionId: string | null
+  createdAt: Date
+}): MarkScope {
+  return { serviceRecordId: job.id, linkedInspectionId: job.inspectionId, openedAt: job.createdAt }
+}
+
+/** The marks the job's map shows, numbered as the web numbers them. */
+export function checkinMarks(
+  all: ConditionMarkData[],
+  scope: MarkScope,
+  panelNames: Record<string, string>
+): CheckinMark[] {
+  const { own, previous } = splitMarks(all, scope)
+  const ownIds = new Set(own.map((m) => m.id))
+  return numberedMarks([...previous, ...own]).map((mark, index) => ({
+    id: mark.id,
+    number: index + 1,
+    bodyType: mark.bodyType,
+    view: mark.view,
+    panel: mark.panel,
+    panelName: panelNames[mark.panel] ?? mark.panel,
+    x: mark.x,
+    y: mark.y,
+    kind: mark.kind,
+    severity: mark.severity,
+    note: mark.note,
+    imageUrls: mark.imageUrls,
+    previous: !ownIds.has(mark.id),
+    editable: ownIds.has(mark.id) && isDrawnOnSheet(mark, scope),
+    recordedAt: new Date(mark.recordedAt).toISOString(),
+  }))
+}
+
+export async function loadCheckin(
+  organizationId: string,
+  jobId: string,
+  locale: string,
+  opts: { body?: string | null } = {}
+): Promise<Checkin | null> {
+  const job = await db.serviceRecord.findFirst({
+    where: { id: jobId, organizationId },
+    select: {
+      id: true,
+      status: true,
+      mileage: true,
+      inspectionId: true,
+      createdAt: true,
+      vehicle: {
+        select: {
+          id: true,
+          make: true,
+          model: true,
+          year: true,
+          licensePlate: true,
+          mileage: true,
+          bodyType: true,
+        },
+      },
+      attachments: {
+        where: { category: 'dropoff' },
+        select: { id: true, description: true, fileUrl: true, createdAt: true },
+        orderBy: { createdAt: 'asc' },
+      },
+    },
+  })
+  if (!job?.vehicle) return null
+  const vehicle = job.vehicle
+
+  const [serviceTypeRow, messages, kinds, marks] = await Promise.all([
+    db.appSetting.findFirst({
+      where: { organizationId, key: SETTING_KEYS.SERVICE_TYPE },
+      select: { value: true },
+    }),
+    loadConditionMapMessages(locale),
+    markTypeCatalogue(organizationId, locale),
+    loadVehicleConditionMarks(organizationId, vehicle.id),
+  ])
+  const serviceType = serviceTypeRow?.value === 'marine' ? 'marine' : 'automotive'
+  const body = isBodyType(opts.body) ? opts.body : bodyTypeFor(vehicle, { serviceType })
+
+  return {
+    job: { id: job.id, mileage: job.mileage, status: job.status },
+    vehicle: {
+      id: vehicle.id,
+      make: vehicle.make,
+      model: vehicle.model,
+      year: vehicle.year,
+      licensePlate: vehicle.licensePlate,
+      mileage: vehicle.mileage,
+      bodyType: isBodyType(vehicle.bodyType) ? vehicle.bodyType : null,
+    },
+    serviceType,
+    bodies: BODY_TYPES.map((key) => ({ key, name: messages.bodies[key] ?? key })),
+    sheet: checkinSheet(body, messages),
+    kinds: kinds.map((kind) => ({
+      key: kind.key,
+      name: kind.name,
+      shape: kind.shape,
+      color: kind.color,
+      hidden: kind.hidden,
+    })),
+    severities: (['minor', 'major'] as const).map((key) => ({
+      key,
+      name: messages.severities[key] ?? key,
+    })),
+    marks: checkinMarks(marks, jobScope(job), messages.panels),
+    photos: job.attachments.map((a) => ({
+      id: a.id,
+      slot: isDropoffSlot(a.description) ? a.description : 'other',
+      url: a.fileUrl,
+      createdAt: a.createdAt.toISOString(),
+    })),
+    slots: DROPOFF_SLOTS,
+  }
+}
+
+/** The job's marks after a change, renumbered, with panel names in a language. */
+export async function checkinMarkList(
+  organizationId: string,
+  vehicleId: string,
+  scope: MarkScope,
+  locale: string
+): Promise<CheckinMark[]> {
+  const [all, messages] = await Promise.all([
+    loadVehicleConditionMarks(organizationId, vehicleId),
+    loadConditionMapMessages(locale),
+  ])
+  return checkinMarks(all, scope, messages.panels)
+}

+ 131 - 0
src/features/condition-map/Lib/checkinAccess.server.ts

@@ -0,0 +1,131 @@
+import 'server-only'
+import type { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { publishRecordChange } from '@/lib/realtime/publish.server'
+import { statusColumns } from '@/features/work-order-statuses/Lib/stages'
+import { announceJobStatusChanged } from '@/features/vehicles/Lib/jobEvents'
+import { type ApiAuthContext, apiError } from '@/lib/with-api-auth'
+import { getBestLocaleFromHeader, resolveDocumentLocale } from '@/i18n/locale-from-request'
+import { type MarkScope } from './marks'
+import { jobScope } from './checkin.server'
+
+/**
+ * The job a technician app request is about, when it is theirs to check in:
+ * this workshop's, assigned to one of their technician rows (an owner or an
+ * admin may check in any job), and with a vehicle to draw. Null otherwise,
+ * so the route answers "not on your list" rather than saying which.
+ */
+export async function checkinJobFor(
+  ctx: ApiAuthContext,
+  id: string
+): Promise<{
+  id: string
+  vehicleId: string
+  status: string
+  /** How the job is named in its history: its number, else its id. */
+  ref: string
+  scope: MarkScope
+} | null> {
+  const job = await db.serviceRecord.findFirst({
+    where: {
+      id,
+      organizationId: ctx.organizationId,
+      ...(ctx.isAdmin ? {} : { technicianId: { in: ctx.technicianIds } }),
+    },
+    select: {
+      id: true,
+      vehicleId: true,
+      status: true,
+      inspectionId: true,
+      createdAt: true,
+      invoiceNumber: true,
+    },
+  })
+  if (!job?.vehicleId) return null
+  return {
+    id: job.id,
+    vehicleId: job.vehicleId,
+    status: job.status,
+    ref: job.invoiceNumber || job.id,
+    scope: jobScope(job),
+  }
+}
+
+/**
+ * The language the app reads in: its own setting, sent as Accept-Language,
+ * else the workshop's. Panel and kind names come back in it.
+ */
+export async function appLocale(request: Request, organizationId: string): Promise<string> {
+  return (
+    getBestLocaleFromHeader(request.headers.get('accept-language')) ??
+    (await resolveDocumentLocale(organizationId))
+  )
+}
+
+/**
+ * The refusals the mark store raises, as answers the app can show: a sheet
+ * that is closed, a kind the workshop does not have, a mark gone meanwhile.
+ * Anything else is a fault and goes to withApiAuth's handler.
+ */
+const KNOWN_REFUSALS: Record<
+  string,
+  { status: number; code: 'not_found' | 'conflict' | 'invalid_request' }
+> = {
+  'Mark not found': { status: 404, code: 'not_found' },
+  'Work order not found': { status: 404, code: 'not_found' },
+  'Unknown kind of mark': { status: 400, code: 'invalid_request' },
+  'A file in this request is not an upload of this workshop': {
+    status: 400,
+    code: 'invalid_request',
+  },
+}
+
+export async function answeringRefusals(run: () => Promise<NextResponse>): Promise<NextResponse> {
+  try {
+    return await run()
+  } catch (err) {
+    const known = err instanceof Error ? KNOWN_REFUSALS[err.message] : undefined
+    if (known && err instanceof Error) return apiError(known.status, known.code, err.message)
+    throw err
+  }
+}
+
+/** Whether a mark was drawn on this job, and so is the app's to change. */
+export async function editableMark(
+  organizationId: string,
+  job: { id: string; vehicleId: string },
+  markId: string
+) {
+  return db.conditionMark.findFirst({
+    where: { id: markId, organizationId, vehicleId: job.vehicleId, serviceRecordId: job.id },
+    select: { id: true, imageUrls: true },
+  })
+}
+
+/**
+ * A booking whose car is being checked in has arrived. Its first check-in
+ * write moves it from `scheduled` to `pending`, before the write itself:
+ * until then an online booking is a placeholder the customer's cancel link
+ * deletes with everything on it, and a car standing in the yard with its
+ * photos taken is no longer that.
+ */
+export async function noteArrival(organizationId: string, job: { id: string; status: string }) {
+  if (job.status !== 'scheduled') return
+  const moved = await db.serviceRecord.updateMany({
+    where: { id: job.id, organizationId, status: 'scheduled' },
+    data: statusColumns('pending'),
+  })
+  if (moved.count > 0) {
+    job.status = 'pending'
+    announceJobStatusChanged({ organizationId, serviceRecordId: job.id, status: 'pending' })
+  }
+}
+
+/**
+ * Tells a work order page open on this job that its marks changed, for a
+ * write the mark itself does not name the job in: clearing an earlier
+ * visit's mark touches that visit's row, not this one.
+ */
+export function announceMarksChanged(organizationId: string, jobId: string) {
+  publishRecordChange({ kind: 'serviceRecord', id: jobId, organizationId, hint: 'conditionMarks' })
+}

+ 58 - 0
src/features/condition-map/Lib/checkinPhoto.server.ts

@@ -0,0 +1,58 @@
+import 'server-only'
+import crypto from 'node:crypto'
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { compressPhoto } from '@/lib/image-upload.server'
+import { uploadsRoot } from '@/lib/upload-root'
+
+/**
+ * A photo from the technician app's check-in, stored the way the web's photo
+ * walk stores one: decoded and re-encoded as a JPEG no wider than the walk's,
+ * under a generated name in the job's folder. What arrives is the phone's
+ * word for what it is; what is kept is only what sharp could read.
+ */
+
+const PHOTO_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic'])
+
+/** A phone's full-size photo is a few megabytes; this leaves room and no more. */
+export const MAX_CHECKIN_PHOTO_BYTES = 30 * 1024 * 1024
+
+export type SavedPhoto = { url: string; fileName: string; size: number; filename: string }
+export type PhotoRefusal = 'noFile' | 'type' | 'empty' | 'tooLarge'
+
+export const PHOTO_REFUSALS: Record<PhotoRefusal, string> = {
+  noFile: 'No photo was attached.',
+  type: 'That file is not a photo we can read.',
+  empty: 'That photo is empty.',
+  tooLarge: 'That photo is too large.',
+}
+
+export async function saveCheckinPhoto(
+  organizationId: string,
+  file: FormDataEntryValue | null
+): Promise<SavedPhoto | { refused: PhotoRefusal }> {
+  if (!(file instanceof File)) return { refused: 'noFile' }
+  if (!PHOTO_TYPES.has(file.type)) return { refused: 'type' }
+  if (file.size === 0) return { refused: 'empty' }
+  if (file.size > MAX_CHECKIN_PHOTO_BYTES) return { refused: 'tooLarge' }
+
+  let data: Buffer
+  try {
+    data = (await compressPhoto(Buffer.from(await file.arrayBuffer()))).data
+  } catch {
+    // Not a picture sharp can read, whatever it called itself.
+    return { refused: 'type' }
+  }
+
+  const filename = `${crypto.randomUUID()}.jpg`
+  const dir = path.join(uploadsRoot(), organizationId, 'services')
+  await mkdir(dir, { recursive: true })
+  await writeFile(path.join(dir, filename), data)
+  return {
+    url: `/api/protected/files/${organizationId}/services/${filename}`,
+    // The phone's name as a label only, with the extension the file has now.
+    fileName: `${(file.name || 'photo').replace(/\.[^.]*$/, '').slice(0, 190)}.jpg`,
+    size: data.length,
+    filename,
+  }
+}

+ 366 - 0
src/features/condition-map/Lib/markStore.server.ts

@@ -0,0 +1,366 @@
+import 'server-only'
+import { db } from '@/lib/db'
+import { releaseFiles } from '@/lib/files/manager'
+import { assertOwnUploads } from '@/lib/upload-url'
+import {
+  type ConditionMarkData,
+  type MarkInput,
+  type MarkPatch,
+  type MarkScope,
+  splitMarks,
+} from './marks'
+import { loadVehicleConditionMarks, MARK_SELECT } from './loadMarks.server'
+
+/**
+ * Writing marks on the condition map: the rules every way in shares. The
+ * web's actions and the technician app's API both call these after their own
+ * sign-in and permission checks, so a mark follows the same rules whichever
+ * screen drew it. Nothing here revalidates a page; the caller does.
+ */
+
+/** A sheet a mark is drawn on: an inspection's check, or a work order. */
+export interface MarkSheet {
+  vehicleId: string
+  inspectionId?: string | null
+  inspectionItemId?: string | null
+  serviceRecordId?: string | null
+}
+
+/**
+ * The sheet a mark is drawn on must belong to this workshop, be for that
+ * vehicle, and be open: a completed inspection keeps its marks as they were.
+ * Answers with the visit the sheet records.
+ */
+export async function assertSheetOpen(
+  organizationId: string,
+  scope: MarkSheet
+): Promise<MarkScope> {
+  if (scope.inspectionId) {
+    const inspection = await db.inspection.findFirst({
+      where: { id: scope.inspectionId, organizationId, vehicleId: scope.vehicleId },
+      select: {
+        status: true,
+        createdAt: true,
+        items: { where: { id: scope.inspectionItemId ?? '' }, select: { id: true } },
+      },
+    })
+    if (!inspection) throw new Error('Inspection not found')
+    if (inspection.status === 'completed')
+      throw new Error('Reopen the inspection to change its condition map')
+    if (scope.inspectionItemId && inspection.items.length === 0) throw new Error('Check not found')
+    return {
+      inspectionId: scope.inspectionId,
+      inspectionItemId: scope.inspectionItemId ?? '',
+      openedAt: inspection.createdAt,
+    }
+  }
+  if (scope.serviceRecordId) {
+    const job = await db.serviceRecord.findFirst({
+      where: { id: scope.serviceRecordId, organizationId, vehicleId: scope.vehicleId },
+      select: { id: true, inspectionId: true, createdAt: true },
+    })
+    if (!job) throw new Error('Work order not found')
+    return {
+      serviceRecordId: job.id,
+      linkedInspectionId: job.inspectionId,
+      openedAt: job.createdAt,
+    }
+  }
+  throw new Error('A mark needs a sheet to be drawn on')
+}
+
+/** The pages that show a sheet's marks. */
+export function markPaths(scope: {
+  vehicleId: string
+  inspectionId?: string | null
+  serviceRecordId?: string | null
+}): string[] {
+  const paths = [`/vehicles/${scope.vehicleId}`]
+  if (scope.inspectionId) paths.push(`/inspections/${scope.inspectionId}`)
+  if (scope.serviceRecordId)
+    paths.push(`/vehicles/${scope.vehicleId}/service/${scope.serviceRecordId}`)
+  return paths
+}
+
+/**
+ * A kind of the workshop's own has to be one of its own. A built-in key is
+ * always known; the schema has already refused anything else.
+ */
+export async function assertKnownKind(organizationId: string, kind: string | undefined) {
+  if (!kind || !kind.startsWith('own_')) return
+  const known = await db.conditionMarkType.count({ where: { organizationId, key: kind } })
+  if (!known) throw new Error('Unknown kind of mark')
+}
+
+export async function ownMark(organizationId: string, id: string): Promise<ConditionMarkData> {
+  const mark = await db.conditionMark.findFirst({
+    where: { id, organizationId },
+    select: MARK_SELECT,
+  })
+  if (!mark) throw new Error('Mark not found')
+  return mark
+}
+
+export async function createMark(
+  organizationId: string,
+  userId: string | null,
+  input: MarkInput
+): Promise<ConditionMarkData> {
+  await assertSheetOpen(organizationId, input)
+  await assertKnownKind(organizationId, input.kind)
+  const mark = await db.conditionMark.create({
+    data: {
+      organizationId,
+      vehicleId: input.vehicleId,
+      inspectionId: input.inspectionId ?? null,
+      inspectionItemId: input.inspectionItemId ?? null,
+      serviceRecordId: input.serviceRecordId ?? null,
+      bodyType: input.bodyType,
+      view: input.view,
+      panel: input.panel,
+      x: input.x,
+      y: input.y,
+      kind: input.kind,
+      severity: input.severity,
+      note: input.note?.trim() || null,
+      recordedById: userId,
+    },
+    select: MARK_SELECT,
+  })
+  // The vehicle remembers which drawing it is marked on.
+  await db.vehicle.updateMany({
+    where: { id: input.vehicleId, organizationId, bodyType: null },
+    data: { bodyType: input.bodyType },
+  })
+  return mark
+}
+
+export async function patchMark(
+  organizationId: string,
+  id: string,
+  patch: MarkPatch
+): Promise<{ before: ConditionMarkData; after: ConditionMarkData }> {
+  const before = await ownMark(organizationId, id)
+  await assertSheetOpen(organizationId, before)
+  await assertKnownKind(organizationId, patch.kind)
+  const after = await db.conditionMark.update({
+    where: { id },
+    data: {
+      ...(patch.kind ? { kind: patch.kind } : {}),
+      ...(patch.severity ? { severity: patch.severity } : {}),
+      ...(patch.note !== undefined ? { note: patch.note?.trim() || null } : {}),
+      ...(patch.x !== undefined ? { x: patch.x } : {}),
+      ...(patch.y !== undefined ? { y: patch.y } : {}),
+      ...(patch.view ? { view: patch.view } : {}),
+      ...(patch.panel ? { panel: patch.panel } : {}),
+    },
+    select: MARK_SELECT,
+  })
+  return { before, after }
+}
+
+export async function deleteMark(organizationId: string, id: string): Promise<ConditionMarkData> {
+  const mark = await ownMark(organizationId, id)
+  await assertSheetOpen(organizationId, mark)
+  await db.conditionMark.delete({ where: { id } })
+  // Its photos go with it, through the file manager like every delete:
+  // to the trash, unless some other row still uses the same file.
+  await releaseFiles(mark.imageUrls, { organizationId, reason: 'condition mark removed' })
+  return mark
+}
+
+/**
+ * A mark from an earlier visit that is no longer there: repaired, or never
+ * was. It stays as history and stops printing as current. `resolved: false`
+ * takes that back.
+ */
+export async function setMarkResolved(
+  organizationId: string,
+  userId: string | null,
+  id: string,
+  resolved: boolean
+): Promise<{ before: ConditionMarkData; after: ConditionMarkData }> {
+  const before = await ownMark(organizationId, id)
+  const after = await db.conditionMark.update({
+    where: { id },
+    data: resolved
+      ? { resolvedAt: new Date(), resolvedById: userId }
+      : { resolvedAt: null, resolvedById: null },
+    select: MARK_SELECT,
+  })
+  return { before, after }
+}
+
+/**
+ * Every mark still open from an earlier visit, confirmed as still there in
+ * one go: each is recorded again on this sheet as this visit's own, and the
+ * earlier one is closed in the same transaction, so the vehicle never carries
+ * the same dent twice and the next visit meets it once.
+ *
+ * The earlier mark is kept rather than moved, because it is the earlier
+ * visit's record: it points at the newer mark, so its own sheet goes on
+ * showing it while every other sheet meets the newer one. The new mark
+ * keeps the old one's photos by pointing at the same files; the file manager
+ * counts both rows, so removing a photo from one leaves the other's in place.
+ *
+ * Safe to ask twice, or by two people at once: an earlier mark is taken only
+ * while it is still open, and whoever comes second finds none left to take.
+ */
+export async function carryMarks(
+  organizationId: string,
+  userId: string | null,
+  sheet: MarkSheet
+): Promise<{ marks: ConditionMarkData[]; carried: ConditionMarkData[] }> {
+  const scope = await assertSheetOpen(organizationId, sheet)
+  const all = await loadVehicleConditionMarks(organizationId, sheet.vehicleId)
+  const earlier = splitMarks(all, scope).previous
+  if (earlier.length === 0) return { marks: [], carried: [] }
+
+  const now = new Date()
+  const carried: ConditionMarkData[] = []
+  const marks = await db.$transaction(async (tx) => {
+    const created: ConditionMarkData[] = []
+    for (const [index, mark] of earlier.entries()) {
+      // Closing it is what claims it. Somebody else who carried or
+      // cleared it a moment ago has closed it already, and it is left.
+      const claimed = await tx.conditionMark.updateMany({
+        where: { id: mark.id, organizationId, resolvedAt: null },
+        data: { resolvedAt: now, resolvedById: userId },
+      })
+      if (claimed.count === 0) continue
+      carried.push(mark)
+      const next = await tx.conditionMark.create({
+        data: {
+          organizationId,
+          vehicleId: mark.vehicleId,
+          inspectionId: sheet.inspectionId ?? null,
+          inspectionItemId: sheet.inspectionItemId ?? null,
+          serviceRecordId: sheet.serviceRecordId ?? null,
+          bodyType: mark.bodyType,
+          view: mark.view,
+          panel: mark.panel,
+          x: mark.x,
+          y: mark.y,
+          kind: mark.kind,
+          severity: mark.severity,
+          note: mark.note,
+          imageUrls: mark.imageUrls,
+          // Marks are numbered by when they were recorded, so each gets
+          // a moment of its own and they keep the order they had.
+          recordedAt: new Date(now.getTime() + index),
+          recordedById: userId,
+        },
+        select: MARK_SELECT,
+      })
+      // Closed because it was superseded, not repaired: the earlier
+      // sheet still prints it as its own record.
+      await tx.conditionMark.update({
+        where: { id: mark.id },
+        data: { carriedToId: next.id },
+      })
+      created.push(next)
+    }
+    return created
+  })
+  return { marks, carried }
+}
+
+export async function addMarkPhotos(
+  organizationId: string,
+  id: string,
+  urls: string[]
+): Promise<{ mark: ConditionMarkData; imageUrls: string[] }> {
+  assertOwnUploads(urls, organizationId)
+  const mark = await ownMark(organizationId, id)
+  await assertSheetOpen(organizationId, mark)
+  const imageUrls = [...mark.imageUrls, ...urls.filter((u) => !mark.imageUrls.includes(u))]
+  await db.conditionMark.update({ where: { id }, data: { imageUrls } })
+  return { mark, imageUrls }
+}
+
+/**
+ * One photo added to a mark, at most `max` on it. The row is locked for the
+ * read and the write, so two uploads at once each land, and the limit holds
+ * against both. Null when the mark is already full.
+ */
+export async function appendMarkPhoto(
+  organizationId: string,
+  id: string,
+  url: string,
+  max: number
+): Promise<{ mark: ConditionMarkData; imageUrls: string[] } | null> {
+  assertOwnUploads([url], organizationId)
+  const mark = await ownMark(organizationId, id)
+  await assertSheetOpen(organizationId, mark)
+  return db.$transaction(async (tx) => {
+    await tx.$queryRaw`SELECT id FROM "public"."condition_marks" WHERE id = ${id} FOR UPDATE`
+    const current = await tx.conditionMark.findFirst({
+      where: { id, organizationId },
+      select: { imageUrls: true },
+    })
+    if (!current) throw new Error('Mark not found')
+    if (current.imageUrls.length >= max) return null
+    const updated = await tx.conditionMark.update({
+      where: { id },
+      data: { imageUrls: { push: url } },
+      select: { imageUrls: true, serviceRecordId: true, inspectionId: true },
+    })
+    return { mark, imageUrls: updated.imageUrls }
+  })
+}
+
+export async function removeMarkPhoto(
+  organizationId: string,
+  id: string,
+  url: string
+): Promise<{ mark: ConditionMarkData; imageUrls: string[] }> {
+  const mark = await ownMark(organizationId, id)
+  await assertSheetOpen(organizationId, mark)
+  // Read and written under the row lock, as an upload is: a photo that
+  // landed while this one was being taken off stays on the mark.
+  const { imageUrls, removed } = await db.$transaction(async (tx) => {
+    await tx.$queryRaw`SELECT id FROM "public"."condition_marks" WHERE id = ${id} FOR UPDATE`
+    const current = await tx.conditionMark.findFirst({
+      where: { id, organizationId },
+      select: { imageUrls: true },
+    })
+    if (!current) throw new Error('Mark not found')
+    const next = current.imageUrls.filter((u) => u !== url)
+    if (next.length !== current.imageUrls.length) {
+      await tx.conditionMark.update({
+        where: { id },
+        data: { imageUrls: next },
+        select: { serviceRecordId: true, inspectionId: true },
+      })
+    }
+    return { imageUrls: next, removed: next.length !== current.imageUrls.length }
+  })
+  // Only a photo this mark had, and only once the row no longer names it.
+  if (removed) {
+    await releaseFiles([url], { organizationId, reason: 'condition mark photo removed' })
+  }
+  return { mark, imageUrls }
+}
+
+/**
+ * Closes an earlier visit's mark as gone, or opens it again, only if nobody
+ * else has closed, reopened or carried it since it was read: the state it
+ * was read in is the condition of the write. False when another writer got
+ * there first.
+ */
+export async function setMarkResolvedIfUnchanged(
+  organizationId: string,
+  userId: string | null,
+  id: string,
+  resolved: boolean
+): Promise<boolean> {
+  const claimed = await db.conditionMark.updateMany({
+    where: resolved
+      ? { id, organizationId, resolvedAt: null, carriedToId: null }
+      : { id, organizationId, resolvedAt: { not: null }, carriedToId: null },
+    data: resolved
+      ? { resolvedAt: new Date(), resolvedById: userId }
+      : { resolvedAt: null, resolvedById: null },
+  })
+  return claimed.count === 1
+}

+ 8 - 0
src/lib/realtime/prisma-realtime.server.ts

@@ -76,6 +76,14 @@ const CHILD_MODELS: Record<
   InspectionAttachment: { parent: 'inspection', fk: 'inspectionId', hint: 'attachments' },
   VehicleFinding: { parent: 'vehicle', fk: 'vehicleId', hint: 'findings' },
   TireSetAttachment: { parent: 'tireSet', fk: 'tireSetId', hint: 'attachments' },
+  // A mark is drawn on a work order's drop-off or on an inspection, so the
+  // page open on that one redraws its map when a mark changes elsewhere.
+  ConditionMark: {
+    parent: 'serviceRecord',
+    fk: 'serviceRecordId',
+    hint: 'conditionMarks',
+    also: { parent: 'inspection', fk: 'inspectionId' },
+  },
   TireMeasurement: { parent: 'tireSet', fk: 'tireSetId', hint: 'measurements' },
 }
 

+ 4 - 1
src/lib/tech-api-features.ts

@@ -13,5 +13,8 @@
  * - `job-video`: videos on a job play in the app (the file route answers byte
  *   ranges), a video may be up to 250 MB and is scaled down to 1080p, and the
  *   job carries each attachment's description.
+ * - `checkin`: a job's check-in under `/jobs/:id/checkin`: mileage, drop-off
+ *   photos by position and the condition map with its marks. A booking is
+ *   on the job list from its day on.
  */
-export const TECH_API_FEATURES = ['job-video'] as const
+export const TECH_API_FEATURES = ['job-video', 'checkin'] as const