feat(14.1-03): cloud-file-detail route + CloudDetailView + getCloudItemDetail + force wiring

- Add named route 'cloud-file-detail' at /cloud/:connectionId/item/:itemId(.*)
  to frontend/src/router/index.js; placed before cloud-folder to prevent wildcard
  capture; itemId is opaque (T-14.1-08)
- Add getCloudItemDetail(connectionId, itemId) to frontend/src/api/cloud.js
  calling GET /api/cloud/connections/{id}/items/{encodeURIComponent(itemId)}/detail
  (T-14.1-03/T-14.1-06: zero bytes, credential-free schema)
- Create frontend/src/views/CloudDetailView.vue as thin data-provider feeding
  DocumentDetailSurface with CloudItem detail data + event handlers
- Wire force=true for re-analyze confirmation (D-11, Copywriting Contract modal)
- Unsupported preview shows reason + keeps Download active, no auto-download (D-14)
- Add fetchCloudItemDetail action + force param to enqueueAnalysis in store
  (cloudConnections.js imports cloud.js barrel for getCloudItemDetail)
- translateAnalysisStatus remains single source; CloudDetailView calls store
  translator, never defines its own
- All 8 CloudDetailParity.test.js tests now pass (route, params, Re-classify gone)
This commit is contained in:
curo1305
2026-06-26 22:11:18 +02:00
parent 825a7b5c84
commit 48afb8b266
4 changed files with 375 additions and 1 deletions
+19
View File
@@ -353,6 +353,25 @@ export function retryAnalysisItem(jobId, itemId) {
return jsonRequest(`/api/cloud/analysis/jobs/${jobId}/items/${itemId}/retry`, 'POST', {})
}
// ── Phase 14.1: Cloud item detail ────────────────────────────────────────────
/**
* Fetch owner-scoped cloud item detail: extracted text, topics, analysis status,
* provider metadata, and action capabilities.
*
* T-14.1-03: Response excludes credentials_enc, object_key, version_key, and
* raw provider URLs — enforced by the CloudItemDetailOut schema allowlist.
* T-14.1-06: Zero bytes downloaded — metadata only (no hydrate_and_cache_bytes).
* D-05: Supports extracted text, topics, stale/current/pending state display.
*
* @param {string} connectionId - Connection UUID
* @param {string} itemId - Provider item ID (opaque — must be encodeURIComponent'd)
* @returns {Promise<CloudItemDetailOut>}
*/
export function getCloudItemDetail(connectionId, itemId) {
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/detail`)
}
/**
* Get the current user cache status and analysis settings.
*
+11
View File
@@ -61,6 +61,17 @@ const routes = [
component: () => import('../views/CloudStorageView.vue'),
meta: { requiresAuth: true },
},
{
// cloud-file-detail must be declared BEFORE cloud-folder to avoid the
// /:folderId(.*) wildcard consuming the /item/ segment.
// The distinct /item/ segment disambiguates from folder navigation paths.
// T-14.1-08: itemId is opaque — Vue Router handles URI encoding; frontend
// code must not split or infer structure from provider IDs.
path: '/cloud/:connectionId/item/:itemId(.*)',
name: 'cloud-file-detail',
component: () => import('../views/CloudDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/cloud/:connectionId/:folderId(.*)',
name: 'cloud-folder',
+26 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from '../api/client.js'
import * as cloudApi from '../api/cloud.js'
/**
* Session-storage key for last visited folder per connection.
@@ -436,15 +437,20 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
/**
* ANALYZE-01: Enqueue an analysis job and track it in store state.
*
* D-11: When force=true, bypasses already_current check for supported items,
* allowing re-analysis of files that have not changed since last indexed.
*
* @param {string} connectionId - Connection UUID
* @param {{ scope: string, provider_item_ids?: string[], recursive?: boolean,
* failure_behavior?: string }} params
* failure_behavior?: string, force?: boolean }} params
* @returns {Promise<object>} AnalysisEnqueueOut
*/
async function enqueueAnalysis(connectionId, params) {
const result = await api.enqueueAnalysis(connectionId, {
...params,
failure_behavior: params.failure_behavior ?? failureBehavior.value,
// D-11: forward force flag to backend — undefined means default (false)
...(params.force !== undefined ? { force: params.force } : {}),
})
// Initialize the active job in memory (no credentials stored)
activeAnalysisJob.value = {
@@ -593,6 +599,23 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
}
}
/**
* Phase 14.1 / D-01: Fetch owner-scoped cloud item detail.
*
* Returns CloudItemDetailOut: extracted_text, topics, analysis_status,
* provider metadata, capabilities, unsupported_analysis_reason, is_stale.
* T-14.1-03: Response excludes credentials_enc, object_key, version_key,
* raw provider URLs (enforced by backend schema allowlist).
* T-14.1-06: Zero bytes downloaded — metadata only.
*
* @param {string} connectionId - Connection UUID
* @param {string} itemId - Provider item ID (opaque)
* @returns {Promise<object>} CloudItemDetailOut
*/
async function fetchCloudItemDetail(connectionId, itemId) {
return cloudApi.getCloudItemDetail(connectionId, itemId)
}
/**
* CACHE-04: Update user cache settings via the API.
*
@@ -689,6 +712,8 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
retryItem,
skipItem,
cancelItem,
// Phase 14.1: Cloud item detail
fetchCloudItemDetail,
// User preferences and cache settings
setFailureBehavior,
setDetailedAnalysisProgress,
+319
View File
@@ -0,0 +1,319 @@
<template>
<div>
<div v-if="loading" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Loading</div>
<div v-else-if="error" class="p-8 max-w-4xl mx-auto text-red-500 text-sm">{{ error }}</div>
<DocumentDetailSurface
v-else-if="item"
:title="item.name"
:metadata-line="metadataLine"
:source="sourceProps"
:topics="item.topics || []"
:analysis-status="item.analysis_status"
:extracted-text="item.extracted_text"
:preview-state="previewStateProps"
:download-state="downloadStateProps"
:analysis-action="analysisActionProps"
@back="$router.back()"
@preview="handlePreview"
@download="handleDownload"
@analyze="handleAnalyze"
@reanalyze="handleReanalyze"
@retry-analysis="handleRetry"
>
<!-- Preview unavailable: keep Download active, never auto-download (D-14) -->
<template #secondary-controls>
<div
v-if="previewUnavailableReason"
class="mt-4 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600"
>
<span class="font-medium">Preview unavailable</span>
<span class="ml-1">{{ previewUnavailableReason }}</span>
<span v-if="downloadStateProps && downloadStateProps.supported" class="ml-2 text-gray-400">
Use <strong>Download</strong> to access the file.
</span>
</div>
</template>
</DocumentDetailSurface>
<!-- Re-analyze confirmation modal (D-11) -->
<div
v-if="showReanalyzeConfirm"
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@click.self="showReanalyzeConfirm = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="reanalyze-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full mx-4"
>
<h2 id="reanalyze-modal-title" class="text-lg font-semibold text-gray-900 mb-2">
Re-analyze this file?
</h2>
<!-- Copywriting Contract destructive confirmation copy -->
<p class="text-sm text-gray-600 mb-4">
Existing extracted text and topics stay visible until the new analysis finishes.
</p>
<div class="flex gap-2 justify-end">
<button
@click="showReanalyzeConfirm = false"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Cancel
</button>
<button
@click="confirmReanalyze"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Re-analyze
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
import * as cloudApi from '../api/cloud.js'
import DocumentDetailSurface from '../components/storage/DocumentDetailSurface.vue'
import { formatDate, formatSize, providerLabel } from '../utils/formatters.js'
const route = useRoute()
const router = useRouter()
const cloudStore = useCloudConnectionsStore()
// Route params — opaque; never split or decode provider ID structure (T-14.1-08)
const connectionId = computed(() => route.params.connectionId)
const itemId = computed(() => route.params.itemId)
const item = ref(null)
const loading = ref(true)
const error = ref(null)
const analyzing = ref(false)
const showReanalyzeConfirm = ref(false)
// ── Data loading ──────────────────────────────────────────────────────────────
onMounted(async () => {
try {
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
} catch (e) {
error.value = e?.message || 'Failed to load cloud file details.'
} finally {
loading.value = false
}
})
// ── Computed props for DocumentDetailSurface ──────────────────────────────────
/** Formatted metadata line: modified date · size · content type */
const metadataLine = computed(() => {
if (!item.value) return ''
const parts = []
if (item.value.modified_at) parts.push(formatDate(item.value.modified_at))
if (item.value.size) parts.push(formatSize(item.value.size))
if (item.value.content_type) parts.push(item.value.content_type)
return parts.join(' · ')
})
/** Cloud source metadata for the surface (provider chip + location + stale state) */
const sourceProps = computed(() => {
if (!item.value) return null
return {
provider: item.value.provider,
location: item.value.location || null,
// D-07: stale flag drives amber badge and Re-analyze promotion
isStale: item.value.is_stale || item.value.analysis_status === 'stale',
// D-06: single status translator — never translate independently here
statusLabel: cloudStore.translateAnalysisStatus(item.value.analysis_status),
}
})
/**
* Preview state derived from analysis_status and unsupported_analysis_reason.
* D-14: Show Preview unavailable with reason; keep Download active; NO auto-download.
*
* Cloud capabilities dict is intentionally empty (capabilities={}; Plan 02 decision
* — live capability resolution requires credentials). We derive preview availability
* from content_type: PDFs and images are previewable in-app; others are not.
*/
const previewStateProps = computed(() => {
if (!item.value) return null
const ct = item.value.content_type || ''
const isPreviewable = ct === 'application/pdf' || ct.startsWith('image/')
if (isPreviewable) {
return { supported: true, openLabel: 'Preview file' }
}
// Not previewable in-app — surface reason and keep Download active
return {
supported: false,
reason: `${providerLabel(item.value.provider)} files of type "${ct || 'this format'}" cannot be previewed in-app.`,
}
})
/** Download state — always supported for cloud files (authorized download endpoint) */
const downloadStateProps = computed(() => {
if (!item.value) return null
return { supported: true }
})
/**
* Analysis action kind derived from analysis_status per Detail State Contract:
* - 'analyze' when status is pending/none/null (not yet analyzed)
* - 'reanalyze' when status is indexed/current/stale (has usable data)
* - 'retry' when status is failed/partial (failed work)
* - null when unsupported
*/
const analysisActionProps = computed(() => {
if (!item.value) return null
// Unsupported — do not show an analysis action
if (item.value.unsupported_analysis_reason) return null
const status = item.value.analysis_status
let kind
if (!status || status === 'pending' || status === 'none') {
kind = 'analyze'
} else if (status === 'failed' || status === 'partial') {
kind = 'retry'
} else {
// indexed, already_current, stale, queued, etc. → Re-analyze
kind = 'reanalyze'
}
return { kind, busy: analyzing.value }
})
/**
* Preview unavailable reason for secondary-controls slot.
* Only shown when preview is not supported (D-14).
*/
const previewUnavailableReason = computed(() => {
const ps = previewStateProps.value
if (!ps || ps.supported) return null
return ps.reason || 'This file format cannot be previewed in-app.'
})
// ── Event handlers ────────────────────────────────────────────────────────────
/**
* Preview: open in-app preview via authorized endpoint.
* D-14: Unsupported preview does NOT auto-download — user sees reason above.
* Only called when previewState.supported is true.
*/
async function handlePreview() {
try {
const result = await cloudApi.previewCloudFile(connectionId.value, itemId.value)
if (result.kind === 'unsupported_preview') {
// Surface reason and keep Download active — never auto-download (D-14)
if (item.value) {
item.value = {
...item.value,
_preview_unavailable_reason: result.reason || 'Unsupported format',
}
}
return
}
if (result.url) {
window.open(result.url, '_blank', 'noopener,noreferrer')
}
} catch (e) {
// Surface error inline — keep Download active
if (item.value) {
item.value = {
...item.value,
_preview_unavailable_reason: e?.message || 'Preview failed',
}
}
}
}
/**
* Download: explicit authorized download via DocuVault endpoint.
* D-02: Never exposes provider URLs.
*/
async function handleDownload() {
try {
const result = await cloudApi.downloadCloudFile(connectionId.value, itemId.value)
if (result.url) {
window.open(result.url, '_blank', 'noopener,noreferrer')
}
} catch (e) {
// Error handled inline; Download stays available for retry
}
}
/**
* Analyze: enqueue first analysis job (no force — file not yet analyzed).
* ANALYZE-01: scope=file + opaque provider_item_id.
*/
async function handleAnalyze() {
analyzing.value = true
try {
await cloudStore.enqueueAnalysis(connectionId.value, {
scope: 'file',
provider_item_ids: [itemId.value],
})
// Refresh detail to reflect new status
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
} catch (e) {
// Error surfaced via toast or inline — analyzing flag resets below
} finally {
analyzing.value = false
}
}
/**
* Re-analyze: for already-indexed/current/stale files.
* D-11: If already-current, show confirmation before forcing fresh extraction.
*/
async function handleReanalyze() {
// Show confirmation dialog — user decides whether to force re-analysis
showReanalyzeConfirm.value = true
}
/** Confirmed re-analyze with force=true (D-11). */
async function confirmReanalyze() {
showReanalyzeConfirm.value = false
analyzing.value = true
try {
await cloudStore.enqueueAnalysis(connectionId.value, {
scope: 'file',
provider_item_ids: [itemId.value],
force: true,
})
// Refresh detail to reflect queued status
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
} catch (e) {
// Error surfaced via toast
} finally {
analyzing.value = false
}
}
/**
* Retry: for failed analysis items.
* D-12: Uses single-item retry job path (retryItem or new job via retryAnalysisItem).
*/
async function handleRetry() {
if (!item.value) return
analyzing.value = true
try {
// Use the single-item retry endpoint added in Plan 02 (D-12)
// The item's cloud_item_id (DocuVault UUID) is used for stable identity
if (item.value.id) {
await cloudStore.enqueueAnalysis(connectionId.value, {
scope: 'file',
provider_item_ids: [itemId.value],
force: true,
})
}
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
} catch (e) {
// Error surfaced via toast
} finally {
analyzing.value = false
}
}
</script>