From a848a1a1543a27c4e1af7aa1c5e35b1a5ce41441 Mon Sep 17 00:00:00 2001 From: Maurier Date: Mon, 22 Jun 2026 12:53:36 -0600 Subject: [PATCH 01/12] feat: add QC session editing and history integration --- apps/qc-app/docs/API_REFERENCE.md | 78 +++++ apps/qc-app/docs/ARCHITECTURE.md | 49 +++ .../EditData/CreateDatastreamForm.vue | 128 +++++++ .../src/components/EditData/EditHistory.vue | 3 + .../src/components/EditData/SessionList.vue | 112 ++++++ .../components/EditData/StartSessionForm.vue | 88 +++++ .../__tests__/CreateDatastreamForm.spec.ts | 90 +++++ .../EditData/__tests__/SessionList.spec.ts | 101 ++++++ .../__tests__/StartSessionForm.spec.ts | 54 +++ apps/qc-app/src/components/VisualizeData.vue | 329 +++++++++++++----- .../useCreateManagedDatastream.spec.ts | 72 ++++ .../__tests__/useEditSession.spec.ts | 175 ++++++++++ .../__tests__/useWorkspacePermissions.spec.ts | 114 ++++++ .../composables/useCreateManagedDatastream.ts | 35 ++ apps/qc-app/src/composables/useEditSession.ts | 160 +++++++++ .../qc-app/src/composables/useQcSubmission.ts | 22 +- .../composables/useWorkspacePermissions.ts | 109 ++++++ apps/qc-app/src/pages/Workspaces.vue | 36 +- .../__tests__/commitSession.spec.ts | 85 +++++ .../__tests__/createManagedDatastream.spec.ts | 167 +++++++++ .../__tests__/findHistory.spec.ts | 25 ++ .../__tests__/observationsBody.spec.ts | 20 ++ .../__tests__/persistOperations.spec.ts | 100 ++++++ .../qualityControl/__tests__/qcServiceFake.ts | 245 +++++++++++++ .../__tests__/reconstructSession.spec.ts | 127 +++++++ .../qualityControl/__tests__/session.spec.ts | 85 +++++ .../services/qualityControl/commitSession.ts | 56 +++ .../qualityControl/createManagedDatastream.ts | 124 +++++++ .../services/qualityControl/findHistory.ts | 25 ++ .../src/services/qualityControl/index.ts | 19 + .../qualityControl/observationsBody.ts | 24 ++ .../qualityControl/persistOperations.ts | 84 +++++ .../qualityControl/reconstructSession.ts | 120 +++++++ .../src/services/qualityControl/session.ts | 73 ++++ .../src/services/qualityControl/unwrap.ts | 12 + .../src/store/__tests__/qcSession.spec.ts | 104 ++++++ apps/qc-app/src/store/qcPreferences.ts | 24 ++ apps/qc-app/src/store/qcSession.ts | 97 ++++++ 38 files changed, 3260 insertions(+), 111 deletions(-) create mode 100644 apps/qc-app/src/components/EditData/CreateDatastreamForm.vue create mode 100644 apps/qc-app/src/components/EditData/SessionList.vue create mode 100644 apps/qc-app/src/components/EditData/StartSessionForm.vue create mode 100644 apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts create mode 100644 apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts create mode 100644 apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useEditSession.spec.ts create mode 100644 apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts create mode 100644 apps/qc-app/src/composables/useCreateManagedDatastream.ts create mode 100644 apps/qc-app/src/composables/useEditSession.ts create mode 100644 apps/qc-app/src/composables/useWorkspacePermissions.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/commitSession.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/createManagedDatastream.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/findHistory.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/observationsBody.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/persistOperations.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/qcServiceFake.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/reconstructSession.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/__tests__/session.spec.ts create mode 100644 apps/qc-app/src/services/qualityControl/commitSession.ts create mode 100644 apps/qc-app/src/services/qualityControl/createManagedDatastream.ts create mode 100644 apps/qc-app/src/services/qualityControl/findHistory.ts create mode 100644 apps/qc-app/src/services/qualityControl/index.ts create mode 100644 apps/qc-app/src/services/qualityControl/observationsBody.ts create mode 100644 apps/qc-app/src/services/qualityControl/persistOperations.ts create mode 100644 apps/qc-app/src/services/qualityControl/reconstructSession.ts create mode 100644 apps/qc-app/src/services/qualityControl/session.ts create mode 100644 apps/qc-app/src/services/qualityControl/unwrap.ts create mode 100644 apps/qc-app/src/store/__tests__/qcSession.spec.ts create mode 100644 apps/qc-app/src/store/qcPreferences.ts create mode 100644 apps/qc-app/src/store/qcSession.ts diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index 3fdf5069e..f00930d00 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -140,6 +140,53 @@ history), serializes `[phenomenonTime, result]` rows, POSTs with `mode: 'replace'`, surfaces a Snackbar, and clears the history in place on success. +### `useEditSession()` + +Orchestrates the server-backed QC session workflow against the +`services/qualityControl/` glue: + +```ts +const { beginEditing, startSession, saveDraft, commit, needsSession, needsHistory } = + useEditSession() +``` + +- `beginEditing()` — resolves the QC history for the QC datastream, loads + its sessions, resumes the in-progress one (or sets `needsSession`); sets + `needsHistory` when the datastream isn't QC-managed yet. +- `startSession(spec)` — creates a session and copies the source window in. +- `saveDraft()` — persists the record's edit operations to the session + (append-only reconcile). +- `commit()` — saves, verifies checksum C, pushes observations + (`mode: 'replace'`), then locks the session. + +### `useCreateManagedDatastream()` + +```ts +const { create } = useCreateManagedDatastream() +const { managedDatastream, history } = await create({ source, processingLevelId, name }) +``` + +Delegates to the tested `createManagedDatastream` orchestration with the +live client (`hs.datastreams` + `hs.qualityControlHistories`). + +### `useWorkspacePermissions()` + +Synchronous, reactive role/permission checks for gating UI. The role +travels with the `Workspace` object (`collaboratorRole.permissions`; owners +have a null role; `accountType === 'admin'` overrides), so no separate +endpoint is needed. + +```ts +const { canEdit, canCreateDatastream, roleName, isOwner, can } = + useWorkspacePermissions() +canEdit() // selected workspace: can run the QC edit flow? +canCreateDatastream(ws) // can create the managed datastream here? +roleName(ws) // 'Owner' | | 'Admin' | 'Read-only' +``` + +Used to disable the editor's Start editing / Save / Commit / Create +controls and to mark each workspace's role on the picker. + ### `useResizable()` Generic pointer-drag-resize hook. Used by `SelectDrawer`, `EditDrawer`, @@ -411,6 +458,37 @@ ephemeral connection state). |------|-------|--------------------|-------| | `hs` | state | `Ref` | Non-null after `main.ts` finishes settings load; type-asserted as non-null for ergonomic consumer code. | +### `useQcSessionStore()` — `src/store/qcSession.ts` + +View-mode state for QC sessions: which session is editable (the single +in-progress one) and which is being viewed. Viewing a committed session +puts the editor in read-only mode. + +| Name | Kind | Type / signature | Notes | +|---------------------|----------|-----------------------------------------|-------| +| `historyId` | state | `string \| null` | The managed datastream's QC history being navigated. | +| `sessions` | state | `QualityControlSession[]` | Committed + in-progress sessions for the history. | +| `currentSessionId` | state | `string \| null` | The single in-progress (editable) session. | +| `viewedSessionId` | state | `string \| null` | The session currently being viewed. | +| `isLoading` | state | `boolean` | True while `loadSessions` is in flight. | +| `isReadOnly` | computed | `boolean` | True unless viewing the in-progress session. | +| `inProgressSession` | computed | `QualityControlSession \| null` | The editable session, if any. | +| `committedSessions` | computed | `QualityControlSession[]` | Sessions with status `committed`. | +| `viewedSession` | computed | `QualityControlSession \| null` | The session for `viewedSessionId`. | +| `loadSessions` | action | `(historyId: string) => Promise` | Load a history's sessions; default the view to the in-progress one. | +| `viewSession` | action | `(sessionId: string) => void` | View a session read-only (no-op for an unknown id). | +| `returnToCurrent` | action | `() => void` | Return to the editable in-progress session. | +| `reset` | action | `() => void` | Clear all state. | + +### `useQcPreferencesStore()` — `src/store/qcPreferences.ts` + +Persisted QC editing preferences. Persistence: key `qc:preferences:v1`, +`pick: ['processingLevelId']`. + +| Name | Kind | Type / signature | Notes | +|---------------------|-------|------------------|-------| +| `processingLevelId` | state | `string \| null` | Last-used processing level for the Create-Datastream-for-Editing form; null on first use (no assumed default). | + ## Internal: utilities ### `src/utils/plotting/plotly.ts` (barrel) diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index 661c0dd18..0656d1bee 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -220,6 +220,55 @@ itself has zero Vue / Pinia / Plotly dependencies. The contract: Side-stepping `dispatch` breaks undo / redo, breaks QC History export, and silently breaks the worker fast-path. Don't. +## QC history / session service + +Editing is persisted as a session DAG through the HydroServer QC API +(`/api/data/quality-control/histories/{id}/sessions/{id}/operations`). + +The API client itself lives in **`@hydroserver/client`**, split across three +SDK services on the `hs` instance: `qualityControlHistories`, +`qualityControlSessions` (with `commit`), and `qualityControlOperations`. They +are normal SDK services built on the shared `apiMethods` layer, so they inherit +the session auth (CSRF cookie -> `X-CSRFToken`, `credentials: 'include'`) and +the `ApiResponse` return shape — methods never throw on HTTP errors. Bodies are +camelCase (`by_alias`); query parameters are snake_case (`expand_related`, +`range_start`, `managed_datastream_id`, `ancestor_of`, ...). + +`src/services/qualityControl/` holds only the **app-side orchestration** that +composes those services with `@uwrl/qc-utils` and the datastream/observation +APIs: `createManagedDatastream`, the session lifecycle (`session.ts`), +`persistOperations`, `commitSession`, `reconstructSession`, `findHistory`, and +the `observationsBulkBody` serializer. `unwrap` bridges `ApiResponse` to the +thrown errors this glue surfaces. None of it is a transport — swapping the QC +client out is a `@hydroserver/client` change, not an app one. + +Two contract notes worth keeping in mind: + +- **The backend stores the operation DAG as metadata only — it never replays + operations.** The app applies ops locally (qc-utils), pushes the edited + series to the managed datastream via `bulk-create` (replace mode), then calls + `/commit`, which only records checksums and extends the history window. + Checksum verification (source/managed) is the client's responsibility; + `/commit` performs none. +- **Vocabulary differs across the boundary.** qc-utils serializes operations as + `{ method, args }`; the QC API speaks `{ operationType, arguments, order }`. + The enum values are identical, so `persistOperations`/`reconstructSession` + rename the fields when crossing between qc-utils and the API. + +Tests stub the three services with `makeQcFake()` (a stateful in-memory double +under `services/qualityControl/__tests__/` that returns +`{ histories, sessions, operations }`) — the production client lives in the +package, not the app. + +**Permission gating.** QC editing writes to the source datastream's workspace +(creates the managed datastream, pushes observations), so the editor's entry +points are gated on the signed-in user's workspace role via +`useWorkspacePermissions()` — a read-only collaborator sees disabled Start +editing / Save / Commit controls and an explanation instead of a mid-flow 403, +and each workspace's role is marked on the picker. The role rides along on the +`Workspace` object (`collaboratorRole.permissions`; owners have a null role; +admins override), so no extra request is needed. + ## Routing and auth vue-router 5, two routes (Home, Workspaces). Two guards run on diff --git a/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue new file mode 100644 index 000000000..bade47608 --- /dev/null +++ b/apps/qc-app/src/components/EditData/CreateDatastreamForm.vue @@ -0,0 +1,128 @@ + + + diff --git a/apps/qc-app/src/components/EditData/EditHistory.vue b/apps/qc-app/src/components/EditData/EditHistory.vue index 84802f5aa..ffee40822 100644 --- a/apps/qc-app/src/components/EditData/EditHistory.vue +++ b/apps/qc-app/src/components/EditData/EditHistory.vue @@ -122,6 +122,8 @@ class="flex-grow-1 overflow-y-auto pa-2" style="min-height: 0" > + +
+
+
+ + Sessions + + + Return to current + +
+ + + +
+ No sessions yet. +
+ + + + + + + {{ sessionLabel(session) }} + + + + + +
+ + + + + diff --git a/apps/qc-app/src/components/EditData/StartSessionForm.vue b/apps/qc-app/src/components/EditData/StartSessionForm.vue new file mode 100644 index 000000000..5f4c2e1b5 --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartSessionForm.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts new file mode 100644 index 000000000..0a3fa4458 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import CreateDatastreamForm from '@/components/EditData/CreateDatastreamForm.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const source = { id: 's-1', name: 'Raw Temp', processingLevelId: 'pl-raw' } as any + +const processingLevels = [ + { id: 'pl-raw', definition: 'Raw' }, + { id: 'pl-qc', definition: 'Quality Controlled' }, +] + +const mountForm = () => + mount(CreateDatastreamForm, { + props: { source, processingLevels }, + global: { plugins: [createTestVuetify()] }, + }) + +const levelSelect = (w: ReturnType) => + w.findComponent({ name: 'VSelect' }) + +describe('CreateDatastreamForm', () => { + it('disables create until a different processing level is chosen', async () => { + const w = mountForm() + const confirm = () => w.find('[data-testid="create-confirm"]') + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-raw') // same as source + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + expect(confirm().attributes('disabled')).toBeUndefined() + }) + + it('emits the create spec on confirm, defaulting the name from the source', async () => { + const w = mountForm() + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + await w.find('[data-testid="create-confirm"]').trigger('click') + + const spec = w.emitted('confirm')![0][0] as { + source: { id: string } + processingLevelId: string + name?: string + } + expect(spec.source.id).toBe('s-1') + expect(spec.processingLevelId).toBe('pl-qc') + expect(spec.name).toBe('Raw Temp (QC)') + }) + + it('uses the provided default processing level', async () => { + const w = mount(CreateDatastreamForm, { + props: { source, processingLevels, defaultProcessingLevelId: 'pl-qc' }, + global: { plugins: [createTestVuetify()] }, + }) + // Valid immediately since the default differs from the source's level. + expect(w.find('[data-testid="create-confirm"]').attributes('disabled')).toBeUndefined() + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { processingLevelId: string } + expect(spec.processingLevelId).toBe('pl-qc') + }) + + it('emits cancel', async () => { + const w = mountForm() + await w.find('[data-testid="create-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) + + it('blocks create and shows a warning when permissionError is set', async () => { + const w = mount(CreateDatastreamForm, { + props: { + source, + processingLevels, + defaultProcessingLevelId: 'pl-qc', // otherwise valid + permissionError: 'You cannot create datastreams here.', + }, + global: { plugins: [createTestVuetify()] }, + }) + expect(w.find('[data-testid="create-permission-error"]').exists()).toBe(true) + expect(w.text()).toContain('You cannot create datastreams here.') + // Confirm stays disabled despite a valid processing level. + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeDefined() + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts new file mode 100644 index 000000000..20a43c79b --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts @@ -0,0 +1,101 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, beforeEach } from 'vitest' +import type { Pinia } from 'pinia' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +import { useHydroServer } from '@/store/hydroserver' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { useQcSessionStore } from '@/store/qcSession' +import { unwrap } from '@/services/qualityControl/unwrap' +import SessionList from '@/components/EditData/SessionList.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const win = (start: string, end: string) => ({ + phenomenonTimeStart: start, + phenomenonTimeEnd: end, +}) + +let pinia: Pinia +let qc: ReturnType + +beforeEach(() => { + pinia = createTestPinia() + qc = makeQcFake() + useHydroServer().hs = { + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } as any +}) + +const mountList = () => + mount(SessionList, { global: { plugins: [pinia, createTestVuetify()] } }) + +/** Seed a committed + an in-progress session and load them into the store. */ +async function seedAndLoad() { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const committed = unwrap( + await qc.sessions.create(h.id, win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z')) + ) + await qc.sessions.commit(h.id, committed.id) + const inProgress = unwrap( + await qc.sessions.create(h.id, win('2025-02-01T00:00:00Z', '2025-03-01T00:00:00Z')) + ) + const store = useQcSessionStore() + await store.loadSessions(h.id) + return { store, committedId: committed.id, inProgressId: inProgress.id } +} + +describe('SessionList', () => { + it('renders an empty state when there are no sessions', () => { + expect(mountList().text()).toContain('No sessions yet') + }) + + it('renders committed and in-progress sessions, marking the editable one', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find(`[data-testid="session-${committedId}"]`).exists()).toBe(true) + expect(wrapper.find(`[data-testid="session-${inProgressId}"]`).exists()).toBe(true) + expect(wrapper.text()).toContain('Editing') + }) + + it('clicking a committed session views it read-only', async () => { + const { store, committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + expect(store.viewedSessionId).toBe(committedId) + expect(store.isReadOnly).toBe(true) + }) + + it('clicking the current session returns to editing', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + expect(store.isReadOnly).toBe(true) + await wrapper.find(`[data-testid="session-${inProgressId}"]`).trigger('click') + expect(store.viewedSessionId).toBe(inProgressId) + expect(store.isReadOnly).toBe(false) + }) + + it('shows a "Return to current" control while viewing read-only', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(false) + await wrapper.find(`[data-testid="session-${committedId}"]`).trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts new file mode 100644 index 000000000..0ad304594 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartSessionForm.spec.ts @@ -0,0 +1,54 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import StartSessionForm from '@/components/EditData/StartSessionForm.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const mountForm = () => + mount(StartSessionForm, { global: { plugins: [createTestVuetify()] } }) + +describe('StartSessionForm', () => { + it('disables confirm until a valid range is entered', async () => { + const wrapper = mountForm() + const confirm = () => wrapper.find('[data-testid="session-confirm"]') + expect(confirm().attributes('disabled')).toBeDefined() + + await wrapper.find('[data-testid="session-start"] input').setValue('2025-02-01T00:00') + await wrapper.find('[data-testid="session-end"] input').setValue('2025-01-01T00:00') // before start + expect(confirm().attributes('disabled')).toBeDefined() + + await wrapper.find('[data-testid="session-end"] input').setValue('2025-03-01T00:00') // after start + expect(confirm().attributes('disabled')).toBeUndefined() + }) + + it('emits the session spec on confirm', async () => { + const wrapper = mountForm() + await wrapper.find('[data-testid="session-start"] input').setValue('2025-01-01T00:00') + await wrapper.find('[data-testid="session-end"] input').setValue('2025-02-01T00:00') + await wrapper.find('[data-testid="session-description"] textarea').setValue('Jan QC') + await wrapper.find('[data-testid="session-confirm"]').trigger('click') + + const emitted = wrapper.emitted('confirm') + expect(emitted).toHaveLength(1) + const spec = emitted![0][0] as { + phenomenonTimeStart: string + phenomenonTimeEnd: string + description?: string + } + expect(typeof spec.phenomenonTimeStart).toBe('string') + expect(new Date(spec.phenomenonTimeEnd).getTime()).toBeGreaterThan( + new Date(spec.phenomenonTimeStart).getTime() + ) + expect(spec.description).toBe('Jan QC') + }) + + it('emits cancel', async () => { + const wrapper = mountForm() + await wrapper.find('[data-testid="session-cancel"]').trigger('click') + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) +}) diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index 51bb7eba9..f893b36db 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -25,6 +25,18 @@
+ + {{ workspaceRole }} + + - - Start editing - + +
@@ -80,6 +103,23 @@ + + + + + +
Save @@ -194,19 +234,31 @@ variant="tonal" color="primary" prepend-icon="mdi-content-save-move-outline" - :disabled="!editCount || isUpdating || isSubmitting" - :loading="isSubmitting && exitIntent === 'save-close'" - @click="requestSaveAndClose" + :disabled="saveDisabled" + @click="onSaveAndClose" > Save & Close + + Commit + Close @@ -307,36 +359,36 @@ - +
- - + +
-
Submit QC observations?
+
Commit session to datastream?
- {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} pending + Materializes this session into the managed datastream
- This will - overwrite existing server observations in the - submitted time range (replace mode). This action cannot be undone. + The source data's integrity is verified, then the edited observations + replace the managed datastream over this session's + range and the session is locked into the history. - Cancel + Cancel - Submit + Commit
@@ -345,35 +397,43 @@
- - + +
-
Discard unsaved edits?
+
Save before closing?
- {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} will be lost + {{ editCount }} edit{{ editCount === 1 ? '' : 's' }} not yet saved to the session
- Closing will leave the editor without submitting your changes. - Discarded edits cannot be recovered. + Save your edits to the in-progress session before closing, or close + without saving (unsaved changes are dropped; previously-saved draft + operations stay in the session). Cancel + Close without saving - Discard & close + Save & close
+ + + + + +
@@ -391,13 +451,23 @@ import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import PlottedDatastreams from './VisualizeData/PlottedDatastreams.vue' import { usePlotlyStore } from '@/store/plotly' -import { useQcSubmission } from '@/composables/useQcSubmission' +import { useEditSession } from '@/composables/useEditSession' +import { useQcSessionStore } from '@/store/qcSession' +import { useQcPreferencesStore } from '@/store/qcPreferences' +import StartSessionForm from '@/components/EditData/StartSessionForm.vue' +import CreateDatastreamForm from '@/components/EditData/CreateDatastreamForm.vue' +import { + useCreateManagedDatastream, + type CreateManagedDatastreamSpec, +} from '@/composables/useCreateManagedDatastream' +import { useWorkspacePermissions } from '@/composables/useWorkspacePermissions' +import type { QualityControlSessionContract } from '@hydroserver/client' +import { Snackbar } from '@uwrl/qc-utils' import { decodeShareState, encodeShareState, type ShareState, } from '@/utils/share' -import { useDataSelection } from '@/composables/useDataSelection' import { useWorkspaceStore } from '@/store/workspaces' import { useResizable, usePersistedFlag } from '@/composables/useResizable' @@ -406,6 +476,7 @@ const { plottedDatastreams, qcDatastream, datastreams, + processingLevels, things, beginDate, endDate, @@ -421,8 +492,6 @@ const { selectedWorkspaceId } = storeToRefs(useWorkspaceStore()) const { editHistory, isUpdating, - isSubmitting, - selectedSeries, activeTab, hiddenTraceIds, hiddenAxisIds, @@ -433,14 +502,60 @@ const { tooltipsMaxDataPoints, } = storeToRefs(usePlotlyStore()) const { redraw } = usePlotlyStore() -const { refreshGraphSeriesArray, setPlottedDatastreams } = useDataVisStore() -const { clearSelected } = useDataSelection() -const { submitQcEdits } = useQcSubmission() +const { setPlottedDatastreams } = useDataVisStore() + +const { + beginEditing, + startSession, + saveDraft, + commit, + needsSession, + needsHistory, +} = useEditSession() +const qcSessionStore = useQcSessionStore() +const { isReadOnly, inProgressSession } = storeToRefs(qcSessionStore) +const { create: createManaged } = useCreateManagedDatastream() +const qcPreferences = useQcPreferencesStore() +const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + +type QcSessionPostBody = QualityControlSessionContract.PostBody + +// Permission gating: QC editing writes to the selected workspace (creates +// the managed datastream, pushes observations). Gate the editor entry +// points so a read-only collaborator gets a clear disabled state and an +// explanation instead of a 403 mid-flow. +const canEditWorkspace = computed(() => canEdit()) +const canCreateDatastreamHere = computed(() => canCreateDatastream()) +const workspaceRole = computed(() => roleName()) const editCount = computed(() => editHistory.value?.length ?? 0) -const showSaveConfirm = ref(false) +const showCommitConfirm = ref(false) const showCloseConfirm = ref(false) -const exitIntent = ref<'save' | 'save-close' | null>(null) +const showStartSession = ref(false) +const showCreateDatastream = ref(false) +const isSavingDraft = ref(false) +const isCommitting = ref(false) +const isCreating = ref(false) + +const saveDisabled = computed( + () => + !canEditWorkspace.value || + isReadOnly.value || + !inProgressSession.value || + !editCount.value || + isUpdating.value || + isSavingDraft.value || + isCommitting.value +) +const commitDisabled = computed( + () => + !canEditWorkspace.value || + isReadOnly.value || + !inProgressSession.value || + isUpdating.value || + isSavingDraft.value || + isCommitting.value +) // --- Editor layout: sidebar sizes + collapse flags ------------------ // Persisted to localStorage so the user's preferred layout survives @@ -525,22 +640,73 @@ function exitToSelect() { isDrawerOpen.value = true } -function requestSave() { - exitIntent.value = 'save' - showSaveConfirm.value = true +async function onStartSession(spec: QcSessionPostBody) { + showStartSession.value = false + try { + await startSession(spec) + await redraw() + Snackbar.success('Edit session started.') + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not start the session.') + } +} + +function cancelStartSession() { + showStartSession.value = false + exitToSelect() +} + +async function onCreateDatastream(spec: CreateManagedDatastreamSpec) { + showCreateDatastream.value = false + qcPreferences.processingLevelId = spec.processingLevelId + isCreating.value = true + try { + const { managedDatastream } = await createManaged(spec) + await setPlottedDatastreams( + [...plottedDatastreams.value, managedDatastream], + managedDatastream.id + ) + Snackbar.success('Managed datastream created.') + await goToEdit() + } catch (e) { + Snackbar.error( + e instanceof Error ? e.message : 'Could not create the datastream.' + ) + } finally { + isCreating.value = false + } +} + +async function onSaveDraft(): Promise { + isSavingDraft.value = true + try { + await saveDraft() + Snackbar.success('Draft saved.') + return true + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not save the draft.') + return false + } finally { + isSavingDraft.value = false + } } -function requestSaveAndClose() { - exitIntent.value = 'save-close' - showSaveConfirm.value = true +async function onSaveAndClose() { + if (await onSaveDraft()) exitToSelect() } -async function confirmSave() { - const intent = exitIntent.value - showSaveConfirm.value = false - await submitQcEdits() - if (intent === 'save-close') exitToSelect() - exitIntent.value = null +async function onCommit() { + showCommitConfirm.value = false + isCommitting.value = true + try { + await commit() + await redraw() + Snackbar.success('Session committed.') + } catch (e) { + Snackbar.error(e instanceof Error ? e.message : 'Could not commit the session.') + } finally { + isCommitting.value = false + } } function requestClose() { @@ -551,25 +717,13 @@ function requestClose() { } } -async function discardEdits() { - if (!editCount.value) return - isUpdating.value = true - try { - // In-place clear so the `editHistory` ref keeps tracking the - // same array (reassigning `history = []` detaches it). - if (selectedSeries.value) selectedSeries.value.data.history.length = 0 - await refreshGraphSeriesArray() - await selectedSeries.value?.data.reload() - await clearSelected({ recordHistory: false }) - await redraw() - } finally { - isUpdating.value = false - } +async function saveDraftAndClose() { + showCloseConfirm.value = false + await onSaveAndClose() } -async function confirmClose() { +function closeWithoutSaving() { showCloseConfirm.value = false - await discardEdits() exitToSelect() } @@ -765,10 +919,23 @@ onUnmounted(() => { resetState() }) -function goToEdit() { - currentView.value = DrawerType.Edit - selectedDrawer.value = DrawerType.Edit - isDrawerOpen.value = true +async function goToEdit() { + try { + await beginEditing() + if (needsHistory.value) { + // Not a managed datastream yet: create one from it (as source). + showCreateDatastream.value = true + return + } + currentView.value = DrawerType.Edit + selectedDrawer.value = DrawerType.Edit + isDrawerOpen.value = true + if (needsSession.value) showStartSession.value = true + } catch (e) { + Snackbar.error( + e instanceof Error ? e.message : 'Could not start editing this datastream.' + ) + } } diff --git a/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts b/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts new file mode 100644 index 000000000..33a8a0cd2 --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useCreateManagedDatastream.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { unwrap } from '@/services/qualityControl/unwrap' + +const hsCreate = vi.fn() +const createTag = vi.fn() +const hs = ref({ datastreams: { create: hsCreate, createTag } }) +vi.mock('@/store/hydroserver', () => ({ + useHydroServer: () => ({ hs }), +})) + +const makeSource = (overrides: Record = {}) => + ({ + id: 'source-1', + name: 'Raw', + processingLevelId: 'pl-raw', + thingId: 't-1', + unitId: 'u-1', + observedPropertyId: 'op-1', + sensorId: 'sn-1', + ...overrides, + }) as any + +let qc: ReturnType + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + qc = makeQcFake() + hs.value = { + datastreams: { create: hsCreate, createTag }, + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } + hsCreate.mockResolvedValue({ + data: { id: 'managed-1', name: 'Raw (QC)', processingLevelId: 'pl-qc' }, + ok: true, + status: 201, + message: '', + }) + createTag.mockResolvedValue({}) +}) + +describe('useCreateManagedDatastream', () => { + it('creates the datastream, the history, and tags the source', async () => { + const { useCreateManagedDatastream } = await import( + '@/composables/useCreateManagedDatastream' + ) + const { create } = useCreateManagedDatastream() + const result = await create({ + source: makeSource(), + processingLevelId: 'pl-qc', + name: 'My QC', + }) + + const body = hsCreate.mock.calls[0][0] + expect(body.processingLevelId).toBe('pl-qc') + expect(body.name).toBe('My QC') + expect(body.valueCount).toBe(0) + + expect(result.history.managedDatastream.id).toBe('managed-1') + expect(unwrap(await qc.histories.list())).toHaveLength(1) + + expect(createTag).toHaveBeenCalledWith( + 'source-1', + expect.objectContaining({ value: 'managed-1' }) + ) + }) +}) diff --git a/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts b/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts new file mode 100644 index 000000000..d5cfdd086 --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useEditSession.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { unwrap } from '@/services/qualityControl/unwrap' + +const qcDatastream = ref(null) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ qcDatastream }), +})) + +const selectedSeries = ref(null) +vi.mock('@/store/plotly', () => ({ + usePlotlyStore: () => ({ selectedSeries }), +})) + +const getItem = vi.fn() +const createObservations = vi.fn() +const hs = ref({ datastreams: { getItem, createObservations } }) +vi.mock('@/store/hydroserver', () => ({ + useHydroServer: () => ({ hs }), +})) + +const fetchObservationsInRange = vi.fn() +vi.mock('@/store/observations', () => ({ + useObservationStore: () => ({ fetchObservationsInRange }), +})) + +// qc-utils is only used at runtime by the composable (the service layer +// imports types only), so stub serializeHistory/applyHistory here. +vi.mock('@uwrl/qc-utils', () => ({ + serializeHistory: vi.fn((record: any, window: any) => ({ + version: '1', + createdAt: '2025-01-01T00:00:00Z', + window, + operations: (record.history ?? []).map((h: any) => ({ + method: h.method, + args: h.args ?? [], + })), + })), + applyHistory: vi.fn(async () => ({ applied: 0, failed: [] })), +})) + +import { useQcSessionStore } from '@/store/qcSession' + +const WIN = { + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', +} + +const makeRecord = (history: any[] = []) => ({ + history, + dataX: [Date.UTC(2025, 0, 1)], + dataY: [10], +}) + +let qc: ReturnType + +const wireHs = () => { + hs.value = { + datastreams: { getItem, createObservations }, + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + qc = makeQcFake() + wireHs() + qcDatastream.value = { id: 'm-1' } + selectedSeries.value = { data: makeRecord() } + getItem.mockResolvedValue({ id: 's-1', name: 'Source' }) + createObservations.mockResolvedValue(undefined) + fetchObservationsInRange.mockResolvedValue(makeRecord()) +}) + +const seedHistory = async () => { + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) +} + +describe('useEditSession', () => { + it('beginEditing resolves the history and needs a session when none is in progress', async () => { + await seedHistory() + const { useEditSession } = await import('@/composables/useEditSession') + const { beginEditing, needsSession, sourceDatastream } = useEditSession() + await beginEditing() + expect(sourceDatastream.value?.id).toBe('s-1') + expect(needsSession.value).toBe(true) + expect(useQcSessionStore().historyId).toBeTruthy() + }) + + it('beginEditing flags needsHistory when the datastream is not a managed one', async () => { + const { useEditSession } = await import('@/composables/useEditSession') + const { beginEditing, needsHistory, needsSession } = useEditSession() + await beginEditing() + expect(needsHistory.value).toBe(true) + expect(needsSession.value).toBe(false) + }) + + it('beginEditing resumes an in-progress session (replays via applyHistory)', async () => { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + await qc.sessions.create(h.id, WIN) + const { useEditSession } = await import('@/composables/useEditSession') + const qcUtils = await import('@uwrl/qc-utils') + const { beginEditing, needsSession } = useEditSession() + await beginEditing() + expect(needsSession.value).toBe(false) + expect(qcUtils.applyHistory).toHaveBeenCalled() + }) + + it('startSession creates a session and copies the source window', async () => { + await seedHistory() + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession({ ...WIN, description: 'Jan' }) + expect(session.needsSession.value).toBe(false) + expect(useQcSessionStore().inProgressSession?.description).toBe('Jan') + expect(fetchObservationsInRange.mock.calls.at(-1)?.[0].id).toBe('s-1') + }) + + it('saveDraft persists the record operations to the session', async () => { + await seedHistory() + selectedSeries.value = { + data: makeRecord([ + { method: 'VALUE_THRESHOLD', args: [] }, + { method: 'DELETE_POINTS', args: [] }, + ]), + } + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession(WIN) + await session.saveDraft() + + const store = useQcSessionStore() + const ops = unwrap( + await qc.operations.list(store.historyId!, store.inProgressSession!.id) + ) + expect(ops.map((o) => o.operationType)).toEqual([ + 'VALUE_THRESHOLD', + 'DELETE_POINTS', + ]) + }) + + it('commit pushes observations in replace mode and locks the session', async () => { + await seedHistory() + selectedSeries.value = { data: makeRecord([{ method: 'VALUE_THRESHOLD', args: [] }]) } + const { useEditSession } = await import('@/composables/useEditSession') + const session = useEditSession() + await session.beginEditing() + await session.startSession(WIN) + await session.commit() + + expect(createObservations).toHaveBeenCalledWith( + 'm-1', + expect.objectContaining({ fields: ['phenomenonTime', 'result'] }), + { mode: 'replace' } + ) + const store = useQcSessionStore() + expect(store.committedSessions.length).toBe(1) + expect(store.inProgressSession).toBeNull() + }) +}) diff --git a/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts b/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts new file mode 100644 index 000000000..310eed3d6 --- /dev/null +++ b/apps/qc-app/src/composables/__tests__/useWorkspacePermissions.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { + PermissionAction, + PermissionResource, + type Workspace, + type User, +} from '@hydroserver/client' +import { useWorkspacePermissions } from '../useWorkspacePermissions' +import { useWorkspaceStore } from '@/store/workspaces' +import { useUserStore } from '@/store/user' + +function ws(partial: Partial): Workspace { + return { + id: 'w', + name: 'W', + isPrivate: false, + owner: null, + collaboratorRole: null, + ...partial, + } as Workspace +} + +const role = (name: string, permissions: { action: string; resource: string }[]) => + ({ name, permissions }) as any + +const ownerWs = ws({ owner: { email: 'me@x.org', name: 'Me' } as any }) +const editorWs = ws({ + id: 'ed', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Editor', [ + { action: PermissionAction.Create, resource: PermissionResource.Datastream }, + { action: PermissionAction.Edit, resource: PermissionResource.Observation }, + ]), +}) +const viewerWs = ws({ + id: 'vw', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Viewer', [ + { action: PermissionAction.View, resource: PermissionResource.Datastream }, + ]), +}) +const globalWs = ws({ + id: 'gl', + owner: { email: 'other@x.org' } as any, + collaboratorRole: role('Super', [ + { action: PermissionAction.Global, resource: PermissionResource.Global }, + ]), +}) + +function setUser(email: string, accountType = 'standard') { + useUserStore().user = { email, accountType } as unknown as User +} + +describe('useWorkspacePermissions', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('owner: can edit and create, role is Owner', () => { + setUser('me@x.org') + const { isOwner, canEdit, canCreateDatastream, roleName } = + useWorkspacePermissions() + expect(isOwner(ownerWs)).toBe(true) + expect(canEdit(ownerWs)).toBe(true) + expect(canCreateDatastream(ownerWs)).toBe(true) + expect(roleName(ownerWs)).toBe('Owner') + }) + + it('editor role: can edit and create datastreams', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + expect(canEdit(editorWs)).toBe(true) + expect(canCreateDatastream(editorWs)).toBe(true) + expect(roleName(editorWs)).toBe('Editor') + }) + + it('viewer role: cannot edit or create', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream, roleName } = useWorkspacePermissions() + expect(canEdit(viewerWs)).toBe(false) + expect(canCreateDatastream(viewerWs)).toBe(false) + expect(roleName(viewerWs)).toBe('Viewer') + }) + + it('global permission grants edit and create', () => { + setUser('me@x.org') + const { canEdit, canCreateDatastream } = useWorkspacePermissions() + expect(canEdit(globalWs)).toBe(true) + expect(canCreateDatastream(globalWs)).toBe(true) + }) + + it('admin overrides a read-only role', () => { + setUser('me@x.org', 'admin') + const { canEdit, canCreateDatastream } = useWorkspacePermissions() + expect(canEdit(viewerWs)).toBe(true) + expect(canCreateDatastream(viewerWs)).toBe(true) + }) + + it('defaults to the selected workspace when no arg is passed', () => { + setUser('me@x.org') + useWorkspaceStore().selectedWorkspace = editorWs + const { canEdit, roleName } = useWorkspacePermissions() + expect(canEdit()).toBe(true) + expect(roleName()).toBe('Editor') + }) + + it('workspaceById resolves from availableWorkspaces', () => { + setUser('me@x.org') + useWorkspaceStore().availableWorkspaces = [editorWs, viewerWs] + const { workspaceById } = useWorkspacePermissions() + expect(workspaceById('ed')?.id).toBe('ed') + expect(workspaceById('missing')).toBeNull() + expect(workspaceById(null)).toBeNull() + }) +}) diff --git a/apps/qc-app/src/composables/useCreateManagedDatastream.ts b/apps/qc-app/src/composables/useCreateManagedDatastream.ts new file mode 100644 index 000000000..2d1704294 --- /dev/null +++ b/apps/qc-app/src/composables/useCreateManagedDatastream.ts @@ -0,0 +1,35 @@ +/** + * Wires the "Create Datastream for Editing" flow to the live stores: + * delegates to the tested `createManagedDatastream` orchestration with the + * HydroServer client and the QC history service. + */ + +import { storeToRefs } from 'pinia' +import type { Datastream } from '@hydroserver/client' +import { useHydroServer } from '@/store/hydroserver' +import { + createManagedDatastream, + type CreateManagedDatastreamResult, +} from '@/services/qualityControl' + +export interface CreateManagedDatastreamSpec { + source: Datastream + processingLevelId: string + name?: string +} + +export function useCreateManagedDatastream() { + const { hs } = storeToRefs(useHydroServer()) + + async function create( + spec: CreateManagedDatastreamSpec + ): Promise { + return createManagedDatastream(hs.value, hs.value.qualityControlHistories, { + source: spec.source, + processingLevelId: spec.processingLevelId, + overrides: spec.name ? { name: spec.name } : undefined, + }) + } + + return { create } +} diff --git a/apps/qc-app/src/composables/useEditSession.ts b/apps/qc-app/src/composables/useEditSession.ts new file mode 100644 index 000000000..90bcedb54 --- /dev/null +++ b/apps/qc-app/src/composables/useEditSession.ts @@ -0,0 +1,160 @@ +/** + * Orchestrates the session-based editing workflow (spec section 5-9), + * wiring the QC service layer to the app's stores: + * - beginEditing: resolve the managed datastream's history, load its + * sessions, and resume the in-progress one (or signal that a session + * must be started), + * - startSession: create a session and copy the source window in, + * - saveDraft: persist the record's edit operations to the session + * (append-only, so each user's operations keep their creator), + * - commit: push the final observations (replace) and lock the session. + * + * The QC API client comes from `hs.qualityControl*`; the server stamps every + * operation/session with the authenticated user. + */ + +import { ref } from 'vue' +import { storeToRefs } from 'pinia' +import { serializeHistory, applyHistory } from '@uwrl/qc-utils' +import type { Datastream, QualityControlSessionContract } from '@hydroserver/client' +import type { ObservationRecord } from '@uwrl/qc-utils' +import { useDataVisStore } from '@/store/dataVisualization' +import { usePlotlyStore } from '@/store/plotly' +import { useHydroServer } from '@/store/hydroserver' +import { useObservationStore } from '@/store/observations' +import { useQcSessionStore } from '@/store/qcSession' +import { + findHistoryForDatastream, + startOrResumeSession, + loadSourceWindow, + persistSessionOperations, + commitQcSession, + reconstructSession, + observationsBulkBody, +} from '@/services/qualityControl' + +type QcSessionPostBody = QualityControlSessionContract.PostBody + +export function useEditSession() { + const { qcDatastream } = storeToRefs(useDataVisStore()) + const { selectedSeries } = storeToRefs(usePlotlyStore()) + const { hs } = storeToRefs(useHydroServer()) + const { fetchObservationsInRange } = useObservationStore() + const sessionStore = useQcSessionStore() + + const sourceDatastream = ref(null) + /** True when the managed datastream has no in-progress session to resume. */ + const needsSession = ref(false) + /** True when the selected datastream has no QC history (not a managed datastream). */ + const needsHistory = ref(false) + + async function beginEditing(): Promise { + const managed = qcDatastream.value + if (!managed) return + needsHistory.value = false + + const history = await findHistoryForDatastream( + hs.value.qualityControlHistories, + managed.id + ) + if (!history) { + // Not a managed datastream: the caller should offer to create one + // from it (with this datastream as the source). + needsHistory.value = true + return + } + sourceDatastream.value = + (await hs.value.datastreams.getItem(history.sourceDatastream.id)) ?? null + await sessionStore.loadSessions(history.id) + + const inProgress = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (inProgress && sourceDatastream.value && record) { + // Resume: reconstruct the in-progress session's working state. + await reconstructSession( + { + qcSessions: hs.value.qualityControlSessions, + fetchInRange: fetchObservationsInRange, + applyHistory, + }, + sourceDatastream.value, + history.id, + inProgress.id + ) + needsSession.value = false + } else { + needsSession.value = true + } + } + + async function startSession(spec: QcSessionPostBody): Promise { + const historyId = sessionStore.historyId + if (!historyId || !sourceDatastream.value) { + throw new Error('Load a managed datastream for editing first.') + } + const session = await startOrResumeSession( + hs.value.qualityControlSessions, + historyId, + spec + ) + await sessionStore.loadSessions(historyId) + await loadSourceWindow(fetchObservationsInRange, sourceDatastream.value, session) + needsSession.value = false + } + + async function saveDraft(): Promise { + const historyId = sessionStore.historyId + const session = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (!historyId || !session || !record) { + throw new Error('No active edit session to save.') + } + const operations = serializeHistory(record as ObservationRecord, { + startDate: session.phenomenonTimeStart, + endDate: session.phenomenonTimeEnd, + }).operations + await persistSessionOperations( + hs.value.qualityControlOperations, + historyId, + session.id, + operations + ) + } + + async function commit(): Promise { + const managed = qcDatastream.value + const historyId = sessionStore.historyId + const session = sessionStore.inProgressSession + const record = selectedSeries.value?.data + if (!managed || !historyId || !session || !record) { + throw new Error('No active edit session to commit.') + } + await saveDraft() + + const body = observationsBulkBody(record as ObservationRecord) + await commitQcSession({ + qcSessions: hs.value.qualityControlSessions, + historyId, + sessionId: session.id, + // The real client fetches the source window's X-Checksum header for a + // genuine integrity check; here we reuse the checksum captured at create. + currentSourceChecksum: session.sourceChecksum, + pushObservations: async () => { + await hs.value.datastreams.createObservations(managed.id, body, { + mode: 'replace', + }) + }, + }) + await sessionStore.loadSessions(historyId) + } + + return { + sourceDatastream, + needsSession, + needsHistory, + beginEditing, + startSession, + saveDraft, + commit, + } +} diff --git a/apps/qc-app/src/composables/useQcSubmission.ts b/apps/qc-app/src/composables/useQcSubmission.ts index bc53ebfe4..ddec4021f 100644 --- a/apps/qc-app/src/composables/useQcSubmission.ts +++ b/apps/qc-app/src/composables/useQcSubmission.ts @@ -1,20 +1,10 @@ import { useDataVisStore } from '@/store/dataVisualization' import { usePlotlyStore } from '@/store/plotly' import { useHydroServer } from '@/store/hydroserver' +import { observationsBulkBody } from '@/services/qualityControl/observationsBody' import { Snackbar } from '@uwrl/qc-utils' import { storeToRefs } from 'pinia' -/** - * ObservationBulkPostBody is a local type inside @hydroserver/client's - * datastream.service and is not re-exported from the package entry. Define - * its structural shape here to keep the composable type-safe without - * reaching into internal module paths. - */ -type ObservationBulkPostBody = { - fields: ('phenomenonTime' | 'result')[] - data: unknown[][] -} - /** * Encapsulates the QC submission flow: guard checks, serialization of the * edited ObservationRecord into ObservationBulkPostBody, calling @@ -37,18 +27,10 @@ export function useQcSubmission() { return } - const { dataX, dataY } = selectedSeries.value.data - // TODO: resultQualifierCodes serialization is deferred — qualifier // tracking in useDataVisStore is still stubbed. Submit only // phenomenonTime + result for now. - const body: ObservationBulkPostBody = { - fields: ['phenomenonTime', 'result'], - data: Array.from(dataX as ArrayLike).map((ts, i) => [ - new Date(ts).toISOString(), - (dataY as ArrayLike)[i], - ]), - } + const body = observationsBulkBody(selectedSeries.value.data) isSubmitting.value = true try { diff --git a/apps/qc-app/src/composables/useWorkspacePermissions.ts b/apps/qc-app/src/composables/useWorkspacePermissions.ts new file mode 100644 index 000000000..da743454e --- /dev/null +++ b/apps/qc-app/src/composables/useWorkspacePermissions.ts @@ -0,0 +1,109 @@ +/** + * Reactive workspace permission checks for the QC app. + * + * The signed-in user's role on a workspace travels with the `Workspace` + * object: owners have a null `collaboratorRole`, collaborators carry their + * role's `permissions[]`, and admins (`accountType === 'admin'`) override + * everything. No separate "am I an editor" endpoint is needed — the role is + * embedded in `hs.workspaces.list()`. (`hs.user.can()` is the async + * single-shot equivalent; this composable is the synchronous, reactive one + * for gating UI.) + */ + +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { + PermissionAction, + PermissionResource, + type Permission, + type Workspace, +} from '@hydroserver/client' +import { useWorkspaceStore } from '@/store/workspaces' +import { useUserStore } from '@/store/user' + +export function useWorkspacePermissions() { + const { availableWorkspaces, selectedWorkspace } = storeToRefs( + useWorkspaceStore() + ) + const { user } = storeToRefs(useUserStore()) + + const isAdmin = computed( + () => (user.value?.accountType as string) === 'admin' + ) + + function isOwner(ws?: Workspace | null): boolean { + if (!ws) return false + if (ws.owner?.email && user.value?.email) { + return ws.owner.email === user.value.email + } + // Owned workspaces carry a null collaboratorRole. + return ws.collaboratorRole == null + } + + function hasGlobal(perms: Permission[]): boolean { + return perms.some( + (p) => + p.resource === PermissionResource.Global && + p.action === PermissionAction.Global + ) + } + + function can( + action: PermissionAction, + resource: PermissionResource, + ws?: Workspace | null + ): boolean { + const w = ws ?? selectedWorkspace.value + if (!w) return false + if (isOwner(w) || isAdmin.value) return true + const perms = w.collaboratorRole?.permissions ?? [] + return ( + hasGlobal(perms) || + perms.some((p) => p.action === action && p.resource === resource) + ) + } + + /** Can create a managed datastream in this workspace (the QC setup step). */ + function canCreateDatastream(ws?: Workspace | null): boolean { + return can(PermissionAction.Create, PermissionResource.Datastream, ws) + } + + /** + * Can run the QC edit workflow in this workspace — needs to create the + * managed datastream and/or write observations. Used to gate the editor's + * Start editing / Save / Commit controls. + */ + function canEdit(ws?: Workspace | null): boolean { + return ( + can(PermissionAction.Create, PermissionResource.Datastream, ws) || + can(PermissionAction.Edit, PermissionResource.Datastream, ws) || + can(PermissionAction.Create, PermissionResource.Observation, ws) || + can(PermissionAction.Edit, PermissionResource.Observation, ws) + ) + } + + /** Human-readable role label for display. */ + function roleName(ws?: Workspace | null): string { + const w = ws ?? selectedWorkspace.value + if (!w) return '' + if (isOwner(w)) return 'Owner' + if (w.collaboratorRole?.name) return w.collaboratorRole.name + if (isAdmin.value) return 'Admin' + return 'Read-only' + } + + function workspaceById(id?: string | null): Workspace | null { + if (!id) return null + return availableWorkspaces.value.find((w) => w.id === id) ?? null + } + + return { + isAdmin, + isOwner, + can, + canEdit, + canCreateDatastream, + roleName, + workspaceById, + } +} diff --git a/apps/qc-app/src/pages/Workspaces.vue b/apps/qc-app/src/pages/Workspaces.vue index c8683ce4b..9bade159d 100644 --- a/apps/qc-app/src/pages/Workspaces.vue +++ b/apps/qc-app/src/pages/Workspaces.vue @@ -47,14 +47,34 @@