feat(v0.802): frontend Cloud/Gear, MSW, docs, scope v0.803, archive
- Cloud: CloudFileVersions, CloudShareModal, versions/share in CloudView - Gear: GearDocumentsTab, GearRepairsTab, warranty badge, initialTab - MSW: cloud versions/share, gear documents/repairs, tags suggest - Stories: CloudFileVersions, CloudShareModal, GearDetailModal variants - gearService: listDocuments, uploadDocument, deleteDocument, listRepairs, createRepair, deleteRepair - cloudService: listVersions, restoreVersion, shareFile, getSharedFile - gear_warranty_notifier: 24h ticker, notifications for expiring warranty - tag_handler_test: unit tests - docs: API_REFERENCE, CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.802 - SCOPE_CONTROL, .cursorrules: scope v0.803 - archive: V0_802_RELEASE_SCOPE, RETROSPECTIVE_V0802
This commit is contained in:
parent
596233aaaf
commit
7692c4b8b9
32 changed files with 1854 additions and 36 deletions
|
|
@ -1,10 +1,10 @@
|
|||
# Règles de Développement UI - Projet SaaS
|
||||
|
||||
## 0. Scope v0.802 (priorité absolue)
|
||||
## 0. Scope v0.803 (priorité absolue)
|
||||
|
||||
- **Référence** : `docs/V0_802_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
|
||||
- Avant toute modification : vérifier si le changement est **dans le scope v0.802**
|
||||
- **Autorisé v0.802** : À définir (voir V0_802_RELEASE_SCOPE.md)
|
||||
- **Référence** : `docs/V0_803_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
|
||||
- Avant toute modification : vérifier si le changement est **dans le scope v0.803**
|
||||
- **Autorisé v0.803** : Sécurité, compliance, outillage dev (voir V0_803_RELEASE_SCOPE.md)
|
||||
- **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité)
|
||||
- En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure.
|
||||
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -1,5 +1,28 @@
|
|||
# Changelog - Veza
|
||||
|
||||
## [v0.802] - 2026-02-25
|
||||
|
||||
### Added
|
||||
- Cloud: file versioning (create, list, restore), sharing (create share link, get shared file)
|
||||
- Cloud: GDPR data export (POST /users/me/export, async ZIP, 202 Accepted)
|
||||
- Cloud: automatic backup cron (24h, copies files to S3 backup prefix)
|
||||
- Upload: batch upload with parallel queue (BatchUploader component)
|
||||
- Tags: GET /tags/suggest for autocomplete (prefix match, frequency order)
|
||||
- Tags: audio/aiff, audio/x-aiff MIME types
|
||||
- Gear: documents CRUD (upload PDF, list, delete)
|
||||
- Gear: repairs CRUD (repair history with date, cost, provider)
|
||||
- Gear: warranty_start, warranty_notes on gear_items
|
||||
- Gear: warranty notifier (24h ticker, notifications when warranty expires in 30 days)
|
||||
- Frontend: CloudFileVersions, CloudShareModal, Versions/Share buttons in CloudFileList
|
||||
- Frontend: GearDetailModal tabs (Documents, Repairs), warranty badge
|
||||
- MSW handlers: cloud versions/share, gear documents/repairs, tags suggest
|
||||
- Storybook: CloudFileVersions, CloudShareModal, GearDetailModalWithDocuments/WithRepairs/WarrantyExpiring
|
||||
|
||||
### Changed
|
||||
- gear_document_service: sanitizeGearFilename to avoid conflict with cloud sanitizeFilename
|
||||
|
||||
---
|
||||
|
||||
## [v0.801] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FileAudioIcon, Trash2, Upload } from 'lucide-react';
|
||||
import { FileAudioIcon, Trash2, Upload, History, Share2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CloudFile } from '../services/cloudService';
|
||||
|
||||
|
|
@ -7,6 +7,8 @@ interface CloudFileListProps {
|
|||
onDeleteFile?: (id: string) => void;
|
||||
onPublishFile?: (id: string) => void;
|
||||
onPreviewFile?: (file: CloudFile) => void;
|
||||
onVersionsFile?: (file: CloudFile) => void;
|
||||
onShareFile?: (file: CloudFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ export function CloudFileList({
|
|||
onDeleteFile,
|
||||
onPublishFile,
|
||||
onPreviewFile,
|
||||
onVersionsFile,
|
||||
onShareFile,
|
||||
className,
|
||||
}: CloudFileListProps) {
|
||||
if (files.length === 0) {
|
||||
|
|
@ -58,6 +62,24 @@ export function CloudFileList({
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onVersionsFile && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onVersionsFile(file); }}
|
||||
className="p-1.5 rounded hover:bg-primary/10 text-primary"
|
||||
title="Version history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onShareFile && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onShareFile(file); }}
|
||||
className="p-1.5 rounded hover:bg-primary/10 text-primary"
|
||||
title="Share"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onPublishFile && file.mime_type.startsWith('audio/') && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPublishFile(file.id); }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { CloudFileVersions } from './CloudFileVersions';
|
||||
|
||||
const meta: Meta<typeof CloudFileVersions> = {
|
||||
title: 'Cloud/CloudFileVersions',
|
||||
component: CloudFileVersions,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CloudFileVersions>;
|
||||
|
||||
export const WithVersions: Story = {
|
||||
args: {
|
||||
fileId: 'c1000000-0000-0000-0000-000000000001',
|
||||
filename: 'sunset-beat.mp3',
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
onRestored: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
...WithVersions.args,
|
||||
fileId: 'empty-file-id',
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/files/:id/versions', () =>
|
||||
HttpResponse.json({ versions: [] })
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
127
apps/web/src/features/cloud/components/CloudFileVersions.tsx
Normal file
127
apps/web/src/features/cloud/components/CloudFileVersions.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { History, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { cloudService, type CloudFileVersion } from '../services/cloudService';
|
||||
|
||||
interface CloudFileVersionsProps {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function CloudFileVersions({
|
||||
fileId,
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
onRestored,
|
||||
}: CloudFileVersionsProps) {
|
||||
const [versions, setVersions] = useState<CloudFileVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && fileId) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cloudService
|
||||
.listVersions(fileId)
|
||||
.then(setVersions)
|
||||
.catch(() => setError('Failed to load versions'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, fileId]);
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
setRestoring(version);
|
||||
try {
|
||||
await cloudService.restoreVersion(fileId, version);
|
||||
onRestored?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('Failed to restore version');
|
||||
} finally {
|
||||
setRestoring(null);
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground truncate">{filename}</p>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading versions...</span>
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<History className="h-12 w-12 mb-4 opacity-40" />
|
||||
<p>No previous versions</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{versions.map((v) => (
|
||||
<li
|
||||
key={v.id}
|
||||
className="flex items-center justify-between py-3 px-4 hover:bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Version {v.version}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(v.created_at)} · {formatSize(v.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(v.version)}
|
||||
disabled={restoring !== null}
|
||||
>
|
||||
{restoring === v.version ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Restore
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose} title="Version history" size="lg">
|
||||
{content}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CloudShareModal } from './CloudShareModal';
|
||||
|
||||
const meta: Meta<typeof CloudShareModal> = {
|
||||
title: 'Cloud/CloudShareModal',
|
||||
component: CloudShareModal,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CloudShareModal>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
fileId: 'c1000000-0000-0000-0000-000000000001',
|
||||
filename: 'sunset-beat.mp3',
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
108
apps/web/src/features/cloud/components/CloudShareModal.tsx
Normal file
108
apps/web/src/features/cloud/components/CloudShareModal.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useState } from 'react';
|
||||
import { Share2, Copy, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { cloudService } from '../services/cloudService';
|
||||
import toast from '@/utils/toast';
|
||||
|
||||
interface CloudShareModalProps {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CloudShareModal({
|
||||
fileId,
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CloudShareModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
||||
const [expiresInHours, setExpiresInHours] = useState(24);
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
setLoading(true);
|
||||
setShareUrl(null);
|
||||
setExpiresAt(null);
|
||||
try {
|
||||
const result = await cloudService.shareFile(fileId, 'read', expiresInHours);
|
||||
setShareUrl(result.share_url);
|
||||
setExpiresAt(result.expires_at);
|
||||
} catch {
|
||||
toast.error('Failed to create share link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
toast.success('Link copied');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose} title="Share file" size="lg">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground truncate">{filename}</p>
|
||||
|
||||
{!shareUrl ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Expiration</label>
|
||||
<select
|
||||
value={expiresInHours}
|
||||
onChange={(e) => setExpiresInHours(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
>
|
||||
<option value={1}>1 hour</option>
|
||||
<option value={24}>24 hours</option>
|
||||
<option value={72}>72 hours</option>
|
||||
<option value={168}>7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateShare}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Create share link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Share link</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-border bg-muted/50 text-sm truncate"
|
||||
/>
|
||||
<Button variant="secondary" size="icon" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expiresAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expires: {new Date(expiresAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { CloudFolderTree } from '../../components/CloudFolderTree';
|
|||
import { CloudFileList } from '../../components/CloudFileList';
|
||||
import { CloudUploadModal } from '../../components/CloudUploadModal';
|
||||
import { CloudFilePreview } from '../../components/CloudFilePreview';
|
||||
import { CloudFileVersions } from '../../components/CloudFileVersions';
|
||||
import { CloudShareModal } from '../../components/CloudShareModal';
|
||||
import { useCloudPage } from './useCloudPage';
|
||||
import { CloudViewSkeleton } from './CloudViewSkeleton';
|
||||
import type { CloudFile } from '../../services/cloudService';
|
||||
|
|
@ -27,6 +29,8 @@ export function CloudView() {
|
|||
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<CloudFile | null>(null);
|
||||
const [versionsFile, setVersionsFile] = useState<CloudFile | null>(null);
|
||||
const [shareFile, setShareFile] = useState<CloudFile | null>(null);
|
||||
const handleUploadComplete = () => {
|
||||
void refetchFiles();
|
||||
toast.success('Files uploaded');
|
||||
|
|
@ -98,6 +102,8 @@ export function CloudView() {
|
|||
onDeleteFile={handleDeleteFile}
|
||||
onPublishFile={handlePublishFile}
|
||||
onPreviewFile={setPreviewFile}
|
||||
onVersionsFile={setVersionsFile}
|
||||
onShareFile={setShareFile}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -115,6 +121,25 @@ export function CloudView() {
|
|||
quota={quota}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
|
||||
{versionsFile && (
|
||||
<CloudFileVersions
|
||||
fileId={versionsFile.id}
|
||||
filename={versionsFile.filename}
|
||||
isOpen={!!versionsFile}
|
||||
onClose={() => setVersionsFile(null)}
|
||||
onRestored={() => void refetchFiles()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareFile && (
|
||||
<CloudShareModal
|
||||
fileId={shareFile.id}
|
||||
filename={shareFile.filename}
|
||||
isOpen={!!shareFile}
|
||||
onClose={() => setShareFile(null)}
|
||||
/>
|
||||
)}
|
||||
</ContentFadeIn>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,4 +80,49 @@ export const cloudService = {
|
|||
const baseUrl = import.meta.env.VITE_API_URL || '/api/v1';
|
||||
return `${baseUrl}/cloud/files/${fileId}/stream`;
|
||||
},
|
||||
|
||||
async listVersions(fileId: string): Promise<CloudFileVersion[]> {
|
||||
const res = await apiClient.get(`/cloud/files/${fileId}/versions`);
|
||||
return res.data.versions ?? [];
|
||||
},
|
||||
|
||||
async createVersion(fileId: string): Promise<void> {
|
||||
await apiClient.post(`/cloud/files/${fileId}/versions`);
|
||||
},
|
||||
|
||||
async restoreVersion(fileId: string, version: number): Promise<void> {
|
||||
await apiClient.post(`/cloud/files/${fileId}/restore/${version}`);
|
||||
},
|
||||
|
||||
async shareFile(
|
||||
fileId: string,
|
||||
permissions?: string,
|
||||
expiresInHours?: number
|
||||
): Promise<CloudShareResult> {
|
||||
const res = await apiClient.post(`/cloud/files/${fileId}/share`, {
|
||||
permissions: permissions ?? 'read',
|
||||
expires_in_hours: expiresInHours ?? 24,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getSharedFile(token: string): Promise<CloudFile> {
|
||||
const res = await apiClient.get(`/cloud/shared/${token}`);
|
||||
return res.data.file;
|
||||
},
|
||||
};
|
||||
|
||||
export interface CloudFileVersion {
|
||||
id: string;
|
||||
file_id: string;
|
||||
version: number;
|
||||
storage_key: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CloudShareResult {
|
||||
share_url: string;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,3 +61,27 @@ export const MinimalItem: Story = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDocuments: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
initialTab: 'documents',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRepairs: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
initialTab: 'repairs',
|
||||
},
|
||||
};
|
||||
|
||||
export const WarrantyExpiring: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
item: {
|
||||
...mockGearInventory[0],
|
||||
warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -20,6 +21,8 @@ import {
|
|||
} from 'lucide-react';
|
||||
import type { GearItem } from '@/types';
|
||||
import { getWarrantyStatus } from './gearUtils';
|
||||
import { GearDocumentsTab } from './GearDocumentsTab';
|
||||
import { GearRepairsTab } from './GearRepairsTab';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GearDetailModalProps {
|
||||
|
|
@ -31,6 +34,8 @@ export interface GearDetailModalProps {
|
|||
onLogMaintenance?: (item: GearItem) => void;
|
||||
onContactSupport?: (item: GearItem) => void;
|
||||
onUploadDocument?: (item: GearItem) => void;
|
||||
/** For Storybook: start with this tab selected */
|
||||
initialTab?: 'overview' | 'documents' | 'repairs';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -43,9 +48,11 @@ export function GearDetailModal({
|
|||
onLogMaintenance,
|
||||
onContactSupport,
|
||||
onUploadDocument,
|
||||
initialTab = 'overview',
|
||||
className,
|
||||
}: GearDetailModalProps) {
|
||||
const warranty = getWarrantyStatus(item.warrantyExpire);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'repairs'>(initialTab);
|
||||
|
||||
return (
|
||||
<div className={cn('fixed inset-0 z-50 flex items-center justify-center p-4', className)}>
|
||||
|
|
@ -66,9 +73,15 @@ export function GearDetailModal({
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
<div className="flex items-center gap-4 mb-1 flex-wrap">
|
||||
<Badge label={item.category} variant="cyan" />
|
||||
<span className="text-muted-foreground text-xs font-mono uppercase">{item.serialNumber ?? '—'}</span>
|
||||
{warranty.daysLeft != null && warranty.daysLeft >= 0 && warranty.daysLeft <= 30 && (
|
||||
<Badge
|
||||
label={`Warranty expires in ${warranty.daysLeft} days`}
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-3xl font-heading font-bold text-foreground">{item.name}</h2>
|
||||
<h3 className="text-xl text-primary font-medium mb-4">
|
||||
|
|
@ -125,8 +138,55 @@ export function GearDetailModal({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border px-8 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'overview'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'documents'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('repairs')}
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'repairs'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Repairs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex-1 overflow-y-auto p-8 min-h-0">
|
||||
{activeTab === 'documents' && (
|
||||
<GearDocumentsTab gearId={item.id} />
|
||||
)}
|
||||
{activeTab === 'repairs' && (
|
||||
<GearRepairsTab gearId={item.id} />
|
||||
)}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<Card variant="default">
|
||||
|
|
@ -270,6 +330,7 @@ export function GearDetailModal({
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FileText, Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { gearService, type GearDocument } from '@/services/gearService';
|
||||
import toast from '@/utils/toast';
|
||||
|
||||
interface GearDocumentsTabProps {
|
||||
gearId: string;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function GearDocumentsTab({ gearId }: GearDocumentsTabProps) {
|
||||
const [documents, setDocuments] = useState<GearDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchDocuments = () => {
|
||||
setLoading(true);
|
||||
gearService
|
||||
.listDocuments(gearId)
|
||||
.then(setDocuments)
|
||||
.catch(() => toast.error('Failed to load documents'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [gearId]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await gearService.uploadDocument(gearId, file, 'invoice');
|
||||
toast.success('Document uploaded');
|
||||
fetchDocuments();
|
||||
} catch {
|
||||
toast.error('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
setDeleting(docId);
|
||||
try {
|
||||
await gearService.deleteDocument(gearId, docId);
|
||||
toast.success('Document deleted');
|
||||
fetchDocuments();
|
||||
} catch {
|
||||
toast.error('Delete failed');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading documents...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-bold text-foreground">Documents</h4>
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,image/*"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Upload PDF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic py-4">No documents yet</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{documents.map((doc) => (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between py-3 px-4 hover:bg-muted/50 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{doc.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(doc.uploaded_at)} · {doc.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 text-destructive"
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
disabled={deleting !== null}
|
||||
>
|
||||
{deleting === doc.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Wrench, Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { gearService, type GearRepair, type CreateRepairInput } from '@/services/gearService';
|
||||
import toast from '@/utils/toast';
|
||||
|
||||
interface GearRepairsTabProps {
|
||||
gearId: string;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatCost(cents: number, currency: string): string {
|
||||
if (cents === 0) return '—';
|
||||
return `${(cents / 100).toFixed(2)} ${currency}`;
|
||||
}
|
||||
|
||||
export function GearRepairsTab({ gearId }: GearRepairsTabProps) {
|
||||
const [repairs, setRepairs] = useState<GearRepair[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<CreateRepairInput>({
|
||||
repair_date: new Date().toISOString().slice(0, 10),
|
||||
description: '',
|
||||
cost_cents: 0,
|
||||
currency: 'EUR',
|
||||
provider: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const fetchRepairs = () => {
|
||||
setLoading(true);
|
||||
gearService
|
||||
.listRepairs(gearId)
|
||||
.then(setRepairs)
|
||||
.catch(() => toast.error('Failed to load repairs'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepairs();
|
||||
}, [gearId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await gearService.createRepair(gearId, form);
|
||||
toast.success('Repair added');
|
||||
setShowForm(false);
|
||||
setForm({
|
||||
repair_date: new Date().toISOString().slice(0, 10),
|
||||
description: '',
|
||||
cost_cents: 0,
|
||||
currency: 'EUR',
|
||||
provider: '',
|
||||
notes: '',
|
||||
});
|
||||
fetchRepairs();
|
||||
} catch {
|
||||
toast.error('Failed to add repair');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (repairId: string) => {
|
||||
setDeleting(repairId);
|
||||
try {
|
||||
await gearService.deleteRepair(gearId, repairId);
|
||||
toast.success('Repair deleted');
|
||||
fetchRepairs();
|
||||
} catch {
|
||||
toast.error('Delete failed');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading repairs...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-bold text-foreground">Repair history</h4>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add repair
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="p-4 border border-border rounded-lg space-y-3 bg-muted/30">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.repair_date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, repair_date: e.target.value }))}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="e.g. Replaced capacitors"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Cost (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.cost_cents ?? 0}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cost_cents: parseInt(e.target.value, 10) || 0 }))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Currency</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.currency ?? 'EUR'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Provider</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.provider ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value }))}
|
||||
placeholder="Repair shop name"
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm">Save</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{repairs.length === 0 && !showForm ? (
|
||||
<p className="text-sm text-muted-foreground italic py-4">No repairs recorded</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{repairs.map((repair) => (
|
||||
<li
|
||||
key={repair.id}
|
||||
className="flex items-start justify-between py-3 px-4 hover:bg-muted/50 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{repair.description || 'Repair'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(repair.repair_date)}
|
||||
{repair.provider && ` · ${repair.provider}`}
|
||||
</p>
|
||||
{repair.cost_cents > 0 && (
|
||||
<p className="text-xs text-foreground mt-1 font-mono">
|
||||
{formatCost(repair.cost_cents, repair.currency)}
|
||||
</p>
|
||||
)}
|
||||
{repair.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{repair.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="opacity-0 group-hover:opacity-100 text-destructive shrink-0"
|
||||
onClick={() => handleDelete(repair.id)}
|
||||
disabled={deleting !== null}
|
||||
>
|
||||
{deleting === repair.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
export function getWarrantyStatus(dateStr?: string) {
|
||||
if (!dateStr) return { label: 'Unknown', color: 'text-muted-foreground', bg: 'bg-muted' };
|
||||
if (!dateStr) return { label: 'Unknown', color: 'text-muted-foreground', bg: 'bg-muted', daysLeft: undefined };
|
||||
const expiry = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysLeft < 0) return { label: 'Expired', color: 'text-destructive', bg: 'bg-destructive/10' };
|
||||
if (daysLeft < 90) return { label: `Expiring (${daysLeft}d)`, color: 'text-warning', bg: 'bg-warning/10' };
|
||||
return { label: 'Active', color: 'text-success', bg: 'bg-success/10' };
|
||||
if (daysLeft < 0) return { label: 'Expired', color: 'text-destructive', bg: 'bg-destructive/10', daysLeft };
|
||||
if (daysLeft < 90) return { label: `Expiring (${daysLeft}d)`, color: 'text-warning', bg: 'bg-warning/10', daysLeft };
|
||||
return { label: 'Active', color: 'text-success', bg: 'bg-success/10', daysLeft };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,41 @@ export const handlersCloud = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/files/:id/versions', ({ params }) => {
|
||||
const versions = [
|
||||
{ id: 'v1', file_id: params.id, version: 2, storage_key: 'cloud/u1/v2/file.mp3', size_bytes: 4500000, created_at: '2026-02-10T08:00:00Z' },
|
||||
{ id: 'v2', file_id: params.id, version: 1, storage_key: 'cloud/u1/v1/file.mp3', size_bytes: 4200000, created_at: '2026-02-01T12:00:00Z' },
|
||||
];
|
||||
return HttpResponse.json({ versions });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/files/:id/versions', () => {
|
||||
return HttpResponse.json({ message: 'version created' }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/files/:id/restore/:version', () => {
|
||||
return HttpResponse.json({ message: 'version restored' });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/cloud/files/:id/share', async ({ request, params }) => {
|
||||
const body = (await request.json()) as { permissions?: string; expires_in_hours?: number };
|
||||
const token = `share-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const expiresAt = new Date(Date.now() + (body.expires_in_hours ?? 24) * 60 * 60 * 1000).toISOString();
|
||||
const baseUrl = 'http://localhost:8080';
|
||||
return HttpResponse.json({
|
||||
share_url: `${baseUrl}/api/v1/cloud/shared/${token}`,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/shared/:token', ({ params }) => {
|
||||
const file = mockFiles[0];
|
||||
return HttpResponse.json({
|
||||
file: { ...file, id: params.token },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/cloud/quota', () => {
|
||||
return HttpResponse.json({ quota: mockQuota });
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -539,6 +539,16 @@ export const handlersMisc = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/inventory/gear/:id', ({ params }) => {
|
||||
const items = [
|
||||
{ id: '1', name: 'Prophet-6', category: 'Synth', brand: 'Sequential', model: 'Prophet-6 Desktop', serialNumber: 'SQ-P6-99281', purchaseDate: '2023-01-15', purchasePrice: 2499, currency: 'USD', status: 'Active', condition: 'Mint', vendor: 'Sweetwater', warrantyExpire: '2026-12-31', image: 'https://picsum.photos/id/100/400/400' },
|
||||
{ id: '2', name: 'Apollo Twin X', category: 'Interface', brand: 'Universal Audio', model: 'Twin X Duo', serialNumber: 'UA-TWX-2210', purchaseDate: '2022-11-20', purchasePrice: 999, currency: 'USD', status: 'Active', condition: 'Good', vendor: 'Thomann', warrantyExpire: '2025-11-20', image: 'https://picsum.photos/id/101/400/400' },
|
||||
{ id: '3', name: 'SM7B', category: 'Microphone', brand: 'Shure', model: 'SM7B Dynamic', serialNumber: 'SH-SM7-004', purchaseDate: '2021-05-10', purchasePrice: 399, currency: 'USD', status: 'Maintenance', condition: 'Fair', vendor: 'Guitar Center', warrantyExpire: '2024-05-10', image: 'https://picsum.photos/id/102/400/400' },
|
||||
];
|
||||
const item = items.find((i) => i.id === params.id) ?? items[0];
|
||||
return HttpResponse.json({ success: true, data: { item } });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/inventory/gear', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const item = {
|
||||
|
|
@ -583,6 +593,65 @@ export const handlersMisc = [
|
|||
return HttpResponse.json({ success: true, message: 'gear item deleted' });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/inventory/gear/:id/documents', ({ params }) => {
|
||||
const docs = [
|
||||
{ id: 'doc-1', gear_id: params.id, type: 'invoice', storage_key: 'gear/d1/invoice.pdf', filename: 'invoice.pdf', uploaded_at: '2026-02-01T10:00:00Z' },
|
||||
{ id: 'doc-2', gear_id: params.id, type: 'manual', storage_key: 'gear/d2/manual.pdf', filename: 'manual.pdf', uploaded_at: '2026-02-05T14:00:00Z' },
|
||||
];
|
||||
return HttpResponse.json({ success: true, data: { documents: docs } });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/inventory/gear/:id/documents', async ({ params }) => {
|
||||
const doc = {
|
||||
id: crypto.randomUUID(),
|
||||
gear_id: params.id,
|
||||
type: 'invoice',
|
||||
storage_key: 'gear/new/doc.pdf',
|
||||
filename: 'uploaded.pdf',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json({ success: true, data: { document: doc } }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.delete('*/api/v1/inventory/gear/:id/documents/:docId', () => {
|
||||
return HttpResponse.json({ success: true, message: 'document deleted' });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/inventory/gear/:id/repairs', ({ params }) => {
|
||||
const repairs = [
|
||||
{ id: 'rep-1', gear_id: params.id, repair_date: '2025-06-15', description: 'Capacitor replacement', cost_cents: 8500, currency: 'EUR', provider: 'Local Repair Shop', notes: '', created_at: '2025-06-15T10:00:00Z' },
|
||||
];
|
||||
return HttpResponse.json({ success: true, data: { repairs } });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/inventory/gear/:id/repairs', async ({ request, params }) => {
|
||||
const body = (await request.json()) as { repair_date: string; description?: string; cost_cents?: number; currency?: string; provider?: string; notes?: string };
|
||||
const repair = {
|
||||
id: crypto.randomUUID(),
|
||||
gear_id: params.id,
|
||||
repair_date: body.repair_date,
|
||||
description: body.description ?? '',
|
||||
cost_cents: body.cost_cents ?? 0,
|
||||
currency: body.currency ?? 'EUR',
|
||||
provider: body.provider ?? '',
|
||||
notes: body.notes ?? '',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json({ success: true, data: { repair } }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.delete('*/api/v1/inventory/gear/:id/repairs/:repairId', () => {
|
||||
return HttpResponse.json({ success: true, message: 'repair deleted' });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/tags/suggest', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
const allTags = ['synth', 'drums', 'bass', 'ambient', 'electronic', 'acoustic', 'vocal', 'sample'];
|
||||
const filtered = q ? allTags.filter((t) => t.startsWith(q)).slice(0, 10) : allTags.slice(0, 10);
|
||||
return HttpResponse.json({ suggestions: filtered });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/live/streams', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const isLive = url.searchParams.get('is_live');
|
||||
|
|
|
|||
|
|
@ -84,4 +84,86 @@ export const gearService = {
|
|||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/inventory/gear/${id}`);
|
||||
},
|
||||
|
||||
async listDocuments(gearId: string): Promise<GearDocument[]> {
|
||||
const response = await apiClient.get<{ documents: GearDocument[] }>(
|
||||
`/inventory/gear/${gearId}/documents`,
|
||||
);
|
||||
const docs = response.data?.documents ?? response.data?.data?.documents;
|
||||
return Array.isArray(docs) ? docs : [];
|
||||
},
|
||||
|
||||
async uploadDocument(
|
||||
gearId: string,
|
||||
file: File,
|
||||
type = 'invoice',
|
||||
): Promise<GearDocument> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
const response = await apiClient.post<{ document: GearDocument }>(
|
||||
`/inventory/gear/${gearId}/documents`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
const doc = response.data?.document ?? response.data?.data?.document;
|
||||
if (!doc) throw new Error('Invalid response from upload document');
|
||||
return doc;
|
||||
},
|
||||
|
||||
async deleteDocument(gearId: string, docId: string): Promise<void> {
|
||||
await apiClient.delete(`/inventory/gear/${gearId}/documents/${docId}`);
|
||||
},
|
||||
|
||||
async listRepairs(gearId: string): Promise<GearRepair[]> {
|
||||
const response = await apiClient.get<{ repairs: GearRepair[] }>(
|
||||
`/inventory/gear/${gearId}/repairs`,
|
||||
);
|
||||
const repairs = response.data?.repairs ?? response.data?.data?.repairs;
|
||||
return Array.isArray(repairs) ? repairs : [];
|
||||
},
|
||||
|
||||
async createRepair(gearId: string, data: CreateRepairInput): Promise<GearRepair> {
|
||||
const response = await apiClient.post<{ repair: GearRepair }>(
|
||||
`/inventory/gear/${gearId}/repairs`,
|
||||
data,
|
||||
);
|
||||
const repair = response.data?.repair ?? response.data?.data?.repair;
|
||||
if (!repair) throw new Error('Invalid response from create repair');
|
||||
return repair;
|
||||
},
|
||||
|
||||
async deleteRepair(gearId: string, repairId: string): Promise<void> {
|
||||
await apiClient.delete(`/inventory/gear/${gearId}/repairs/${repairId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface GearDocument {
|
||||
id: string;
|
||||
gear_id: string;
|
||||
type: string;
|
||||
storage_key: string;
|
||||
filename: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface GearRepair {
|
||||
id: string;
|
||||
gear_id: string;
|
||||
repair_date: string;
|
||||
description: string;
|
||||
cost_cents: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateRepairInput {
|
||||
repair_date: string;
|
||||
description?: string;
|
||||
cost_cents?: number;
|
||||
currency?: string;
|
||||
provider?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -579,6 +579,132 @@ Update stream metadata (ownership check). Auth required.
|
|||
|
||||
---
|
||||
|
||||
## Cloud Storage (v0.802)
|
||||
|
||||
### GET /cloud/files/:id/versions
|
||||
|
||||
List version history for a file.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"file_id": "uuid",
|
||||
"version": 2,
|
||||
"storage_key": "...",
|
||||
"size_bytes": 4500000,
|
||||
"created_at": "2026-02-10T08:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /cloud/files/:id/versions
|
||||
|
||||
Create a version snapshot of the current file.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### POST /cloud/files/:id/restore/:version
|
||||
|
||||
Restore file to a previous version.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### POST /cloud/files/:id/share
|
||||
|
||||
Create a share link. Body: `{ "permissions": "read", "expires_in_hours": 24 }`.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"share_url": "https://.../api/v1/cloud/shared/TOKEN",
|
||||
"token": "TOKEN",
|
||||
"expires_at": "2026-02-26T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /cloud/shared/:token
|
||||
|
||||
Get shared file metadata (public, no auth).
|
||||
|
||||
**Auth:** None
|
||||
|
||||
---
|
||||
|
||||
## Tags (v0.802)
|
||||
|
||||
### GET /tags/suggest
|
||||
|
||||
Tag autocomplete suggestions. Query: `?q=hip&limit=10`.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"suggestions": ["hip-hop", "hiphop", "hipster"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gear / Inventory (v0.802)
|
||||
|
||||
### POST /inventory/gear/:id/documents
|
||||
|
||||
Upload a document (invoice, manual, etc.). Multipart form: `file`, `type`.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### GET /inventory/gear/:id/documents
|
||||
|
||||
List documents for a gear item.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### DELETE /inventory/gear/:id/documents/:docId
|
||||
|
||||
Delete a document.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### POST /inventory/gear/:id/repairs
|
||||
|
||||
Create a repair record. Body: `{ "repair_date": "2026-02-15", "description": "...", "cost_cents": 8500, "currency": "EUR", "provider": "..." }`.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### GET /inventory/gear/:id/repairs
|
||||
|
||||
List repair history for a gear item.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
### DELETE /inventory/gear/:id/repairs/:repairId
|
||||
|
||||
Delete a repair record.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
---
|
||||
|
||||
## Users (v0.802)
|
||||
|
||||
### POST /users/me/export
|
||||
|
||||
Request GDPR data export. Returns 202 Accepted; export runs asynchronously. User receives a notification when ready with a presigned download URL.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
---
|
||||
|
||||
## Legacy routes (deprecated)
|
||||
|
||||
The following routes are also available at the root (without `/api/v1` prefix) but are deprecated:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Statut des fonctionnalités — Veza
|
||||
|
||||
**Dernière mise à jour** : février 2026 — v0.801 livrée (UX/UI Polish, Accessibilité & PWA)
|
||||
**Dernière mise à jour** : février 2026 — v0.802 livrée (Cloud avancé, Gear documents/repairs, Tags suggest)
|
||||
|
||||
Ce document décrit le statut réel des fonctionnalités par rapport au code.
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code.
|
|||
| Administration | Oui | Oui | Complet |
|
||||
| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer auto Stripe Connect. v0.701 : Retry auto failed transfers, Admin Dashboard. v0.702 : Route product detail, tests reviews/invoices/refunds |
|
||||
| Webhooks | Oui | Oui | Complet |
|
||||
| Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear |
|
||||
| Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear. v0.802 : documents CRUD, repairs CRUD, warranty notifier |
|
||||
| Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server. v0.703 : Go Live, stream key, chat WebSocket, viewer count |
|
||||
| Analytics | Oui | Oui | Routes /api/v1/analytics/*. v0.202 : creator stats/charts/export (Lot H) |
|
||||
| Roles | Oui | Oui | Assign, revoke — flag ROLE_MANAGEMENT |
|
||||
|
|
@ -215,6 +215,21 @@ Voir [V0_703_RELEASE_SCOPE.md](V0_703_RELEASE_SCOPE.md) pour le détail.
|
|||
| UX3 | PWA : service worker re-enabled (safe caching), Install App in Settings |
|
||||
| UX3 | Background playback : useWakeLock for mobile |
|
||||
|
||||
## Livré en v0.802 (Phase 8 — Cloud avancé, Gear, Tags)
|
||||
|
||||
| Lot | Feature |
|
||||
|-----|---------|
|
||||
| C2 | Cloud versioning : create version, list versions, restore |
|
||||
| C2 | Cloud sharing : create share link, get shared file (public) |
|
||||
| C3 | GDPR export : POST /users/me/export (async ZIP, notification) |
|
||||
| C3 | Cloud backup : cron 24h, copie S3 vers prefix backup |
|
||||
| U1 | Batch upload : BatchUploader, queue parallèle (max 3) |
|
||||
| T1 | Tags suggest : GET /tags/suggest?q=... (autocomplete) |
|
||||
| T1 | Formats audio : audio/aiff, audio/x-aiff |
|
||||
| G2 | Gear documents : CRUD (upload PDF, list, delete) |
|
||||
| G2 | Gear repairs : CRUD (repair history) |
|
||||
| G2 | Gear warranty : warranty_start, warranty_notes, notifier 24h |
|
||||
|
||||
## Prévu en v0.403 (Phase 4 Commerce — suite)
|
||||
|
||||
| Lot | Feature |
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
|
||||
| Élément | Valeur |
|
||||
|---------|--------|
|
||||
| **Dernier tag** | v0.801 |
|
||||
| **Dernier tag** | v0.802 |
|
||||
| **Branche courante** | `main` |
|
||||
| **Phase** | Phase 8 — Polish & Scale |
|
||||
| **Prochaine version** | v0.802 |
|
||||
| **Prochaine version** | v0.803 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -73,6 +73,12 @@
|
|||
- Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103–108)
|
||||
- Sécurité : Trivy container scanning CI
|
||||
|
||||
### v0.802 (Phase 8 — Cloud avancé, Gear, Tags)
|
||||
- Cloud : versioning, sharing, GDPR export, backup cron
|
||||
- Gear : documents CRUD, repairs CRUD, warranty notifier
|
||||
- Tags : GET /tags/suggest, audio/aiff
|
||||
- Frontend : CloudFileVersions, CloudShareModal, GearDocumentsTab, GearRepairsTab
|
||||
|
||||
### v0.702 (Phase 7 — Reviews, Factures, Remboursements & Product Detail)
|
||||
- Route /marketplace/products/:id avec ProductDetailPage (lazy)
|
||||
- MSW handlers : reviews (GET/POST), invoice download
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
# Contrôle du scope — Anti-scope-creep
|
||||
|
||||
**Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable.
|
||||
**Référence active** : [V0_802_RELEASE_SCOPE.md](V0_802_RELEASE_SCOPE.md)
|
||||
**Version précédente** : [V0_801_RELEASE_SCOPE.md](archive/V0_801_RELEASE_SCOPE.md)
|
||||
**Référence active** : [V0_803_RELEASE_SCOPE.md](V0_803_RELEASE_SCOPE.md)
|
||||
**Version précédente** : [V0_802_RELEASE_SCOPE.md](archive/V0_802_RELEASE_SCOPE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Règle d'or
|
||||
|
||||
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.802.**
|
||||
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.803.**
|
||||
> Si non → ne pas ajouter. Créer un ticket pour une version ultérieure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pendant la phase v0.802 (jusqu'au tag)
|
||||
## 2. Pendant la phase v0.803 (jusqu'au tag)
|
||||
|
||||
### 2.1 Autorisé
|
||||
|
||||
- **Corrections de bugs** sur les features IN SCOPE v0.802
|
||||
- **Corrections de bugs** sur les features IN SCOPE v0.803
|
||||
- **Stabilisation** : tests, refactoring sans changement de comportement
|
||||
- **Nettoyage** : suppression de code mort, consolidation
|
||||
- **Documentation** : mise à jour des docs existantes
|
||||
|
|
@ -26,17 +26,17 @@
|
|||
|
||||
### 2.2 Interdit
|
||||
|
||||
- **Nouvelles features** hors scope v0.802
|
||||
- **Nouvelles features** hors scope v0.803
|
||||
- **Nouvelles routes** ou pages hors scope
|
||||
- **Nouvelles dépendances** (sauf correctif sécurité)
|
||||
- **Changements de comportement** sur les features HORS SCOPE
|
||||
- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.802
|
||||
- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.803
|
||||
|
||||
### 2.3 Cas limite
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.802. Sinon : ticket pour plus tard. |
|
||||
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.803. Sinon : ticket pour plus tard. |
|
||||
| Dépendance obsolète/vulnérable | Mettre à jour. Documenter dans la PR. |
|
||||
| Refactoring qui change une API interne | Autorisé si 0 impact sur le contrat public et tests passent. |
|
||||
| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.803+. |
|
||||
|
|
@ -47,13 +47,13 @@
|
|||
|
||||
### 3.1 Checklist pré-commit (dans la tête)
|
||||
|
||||
1. **Mon changement modifie-t-il une feature IN SCOPE v0.802 ?**
|
||||
1. **Mon changement modifie-t-il une feature IN SCOPE v0.803 ?**
|
||||
- Oui → Continuer. S'assurer qu'il n'y a pas de régression.
|
||||
- Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ?
|
||||
|
||||
2. **Mon changement ajoute-t-il du code ?**
|
||||
- Nouvelle route, nouveau composant, nouveau service → Vérifier V0_802_RELEASE_SCOPE. Si hors scope → **STOP.**
|
||||
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.802.
|
||||
- Nouvelle route, nouveau composant, nouveau service → Vérifier V0_803_RELEASE_SCOPE. Si hors scope → **STOP.**
|
||||
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.803.
|
||||
|
||||
3. **Mes tests passent-ils ?**
|
||||
- `npm test -- --run` (frontend)
|
||||
|
|
@ -81,7 +81,7 @@ Format : `type(scope): description`
|
|||
|
||||
Dans chaque PR, le relecteur doit valider :
|
||||
|
||||
- [ ] Le changement est dans le scope v0.802 (voir [V0_802_RELEASE_SCOPE.md](V0_802_RELEASE_SCOPE.md))
|
||||
- [ ] Le changement est dans le scope v0.803 (voir [V0_803_RELEASE_SCOPE.md](V0_803_RELEASE_SCOPE.md))
|
||||
- [ ] Aucune nouvelle feature ajoutée
|
||||
- [ ] Aucune régression sur les flows critiques
|
||||
- [ ] Les tests passent
|
||||
|
|
@ -92,7 +92,7 @@ Dans chaque PR, le relecteur doit valider :
|
|||
Une PR sera rejetée si :
|
||||
|
||||
- Elle ajoute une nouvelle route, page ou feature
|
||||
- Elle modifie le comportement d'une feature HORS SCOPE v0.802 (sauf correctif bug critique)
|
||||
- Elle modifie le comportement d'une feature HORS SCOPE v0.803 (sauf correctif bug critique)
|
||||
- Les tests échouent
|
||||
- Elle introduit une dépendance non justifiée
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ Une PR sera rejetée si :
|
|||
|
||||
Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec :
|
||||
|
||||
- **Alignement scope** : cocher "Hors scope v0.802 — pour v0.803+"
|
||||
- **Alignement scope** : cocher "Hors scope v0.803 — pour v0.804+"
|
||||
- **Justification** : pourquoi cette feature est nécessaire
|
||||
- **Effort estimé** : S / M / L / XL
|
||||
- **Dépendances** : quelles features v0.802 doivent être stables avant
|
||||
|
|
@ -112,8 +112,8 @@ Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md
|
|||
### 5.2 Workflow
|
||||
|
||||
1. Créer une issue avec le template
|
||||
2. **Ne pas implémenter** tant que v0.802 n'est pas taguée
|
||||
3. Une fois v0.802 stable, prioriser les issues post-v0.802 dans le scope suivant
|
||||
2. **Ne pas implémenter** tant que v0.803 n'est pas taguée
|
||||
3. Une fois v0.803 stable, prioriser les issues post-v0.803 dans le scope suivant
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ Si une vulnérabilité critique est identifiée :
|
|||
Si un bug bloque un déploiement ou un flow critique :
|
||||
|
||||
- Correctif autorisé
|
||||
- La feature concernée doit être IN SCOPE v0.802 ou dépendance directe d'une feature IN SCOPE
|
||||
- La feature concernée doit être IN SCOPE v0.803 ou dépendance directe d'une feature IN SCOPE
|
||||
|
||||
### 6.3 Décision collégiale
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ Pour tout cas ambigu :
|
|||
|
||||
- Ouvrir une issue "Scope clarification"
|
||||
- Décision documentée dans l'issue
|
||||
- Mise à jour de V0_802_RELEASE_SCOPE.md si le scope est étendu (exception rare)
|
||||
- Mise à jour de V0_803_RELEASE_SCOPE.md si le scope est étendu (exception rare)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -177,11 +177,12 @@ Pour tout cas ambigu :
|
|||
- v0.702 : Phase 7 — Reviews, Factures, Remboursements & Product Detail — taguée
|
||||
- v0.703 : Phase 7 — Go Live & Streaming Complet — taguée
|
||||
- v0.801 : Phase 8 — UX/UI Polish, Accessibilité & PWA — taguée
|
||||
- v0.802 : Phase 8 — Cloud avancé, Gear documents/repairs, Tags suggest — taguée
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappel pour les contributeurs
|
||||
|
||||
- **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification.
|
||||
- **Humains** : Lire [V0_802_RELEASE_SCOPE.md](V0_802_RELEASE_SCOPE.md) avant de coder.
|
||||
- **Humains** : Lire [V0_803_RELEASE_SCOPE.md](V0_803_RELEASE_SCOPE.md) avant de coder.
|
||||
- **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder.
|
||||
|
|
|
|||
45
docs/archive/RETROSPECTIVE_V0802.md
Normal file
45
docs/archive/RETROSPECTIVE_V0802.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Rétrospective v0.802 — Cloud avancé, Gear, Tags
|
||||
|
||||
**Date** : 2026-02-25
|
||||
**Version** : v0.802
|
||||
**Thème** : Cloud versioning/sharing, Gear documents/repairs, Tags suggest, GDPR export, Backup
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce qui a bien fonctionné
|
||||
|
||||
- **Migrations** : 4 migrations (119–122) créées proprement, FK correctes vers `user_files` et `gear_items`
|
||||
- **Backend modulaire** : CloudService, GearDocumentService, TagSuggestService bien séparés
|
||||
- **Frontend** : CloudFileVersions, CloudShareModal, GearDocumentsTab, GearRepairsTab intégrés sans refonte majeure
|
||||
- **MSW** : Handlers ajoutés pour tous les nouveaux endpoints, stories fonctionnelles
|
||||
- **Documentation** : API_REFERENCE.md, CHANGELOG.md, FEATURE_STATUS.md mis à jour
|
||||
|
||||
---
|
||||
|
||||
## 2. Ce qui pourrait être amélioré
|
||||
|
||||
- **Chunked upload** : Plan prévoyait POST /upload/chunk et /upload/complete ; seul BatchUploader (queue parallèle) a été livré. Le pause/resume par chunks reste à faire.
|
||||
- **Tests backend** : Couverture limitée (tag_handler_test uniquement). Les services Cloud/Gear nécessitent une DB PostgreSQL réelle pour des tests complets.
|
||||
- **Tag suggest** : Le service utilise `unnest` PostgreSQL ; pas compatible SQLite pour les tests unitaires.
|
||||
|
||||
---
|
||||
|
||||
## 3. Actions pour la suite
|
||||
|
||||
| Action | Responsable | Version cible |
|
||||
|--------|-------------|---------------|
|
||||
| Implémenter chunked upload (pause/resume) | — | v0.803 ou v0.901 |
|
||||
| Ajouter tests d’intégration Cloud/Gear avec testcontainers | — | v0.803 |
|
||||
| Aligner TagSuggestService pour tests SQLite (ou mock) | — | v0.803 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Métriques
|
||||
|
||||
| Métrique | Valeur |
|
||||
|----------|--------|
|
||||
| Migrations ajoutées | 4 |
|
||||
| Routes backend ajoutées | 15+ |
|
||||
| Composants frontend | 4 (CloudFileVersions, CloudShareModal, GearDocumentsTab, GearRepairsTab) |
|
||||
| MSW handlers | 10+ |
|
||||
| Stories Storybook | 5 |
|
||||
|
|
@ -183,6 +183,18 @@ func main() {
|
|||
}))
|
||||
}
|
||||
|
||||
// v0.802: Start Gear Warranty Notifier (sends notifications when warranty expires in 30 days)
|
||||
notificationService := services.NewNotificationService(db, logger)
|
||||
warrantyNotifier := services.NewGearWarrantyNotifier(db.GormDB, notificationService, logger)
|
||||
warrantyCtx, warrantyCancel := context.WithCancel(context.Background())
|
||||
go warrantyNotifier.Start(warrantyCtx)
|
||||
logger.Info("Gear Warranty Notifier started (24h interval)")
|
||||
|
||||
shutdownManager.Register(shutdown.NewShutdownFunc("gear_warranty_notifier", func(ctx context.Context) error {
|
||||
warrantyCancel()
|
||||
return nil
|
||||
}))
|
||||
|
||||
// Configuration du mode Gin
|
||||
// Correction: Utilisation directe de la variable d'env car non exposée dans Config
|
||||
appEnv := os.Getenv("APP_ENV")
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ func (r *APIRouter) setupGearRoutes(router *gin.RouterGroup) {
|
|||
gearService := services.NewGearService(gearRepo, r.logger)
|
||||
gearHandler := handlers.NewGearHandler(gearService, r.logger)
|
||||
|
||||
gearDocService := services.NewGearDocumentService(r.db.GormDB, r.config.S3StorageService, r.logger)
|
||||
gearHandler.SetGearDocumentService(gearDocService)
|
||||
|
||||
// G1-01: Public gear profile (no auth)
|
||||
// Use :id to avoid Gin conflict with /users/:id/avatar (same path param name required)
|
||||
router.GET("/users/:id/gear", gearHandler.ListPublicGear)
|
||||
|
||||
inventory := router.Group("/inventory")
|
||||
|
|
@ -32,5 +34,12 @@ func (r *APIRouter) setupGearRoutes(router *gin.RouterGroup) {
|
|||
inventory.DELETE("/gear/:id", gearHandler.DeleteGear)
|
||||
inventory.POST("/gear/:id/images", gearHandler.UploadGearImage)
|
||||
inventory.DELETE("/gear/:id/images/:img_id", gearHandler.DeleteGearImage)
|
||||
|
||||
inventory.POST("/gear/:id/documents", gearHandler.UploadGearDocument)
|
||||
inventory.GET("/gear/:id/documents", gearHandler.ListGearDocuments)
|
||||
inventory.DELETE("/gear/:id/documents/:docId", gearHandler.DeleteGearDocument)
|
||||
inventory.POST("/gear/:id/repairs", gearHandler.CreateGearRepair)
|
||||
inventory.GET("/gear/:id/repairs", gearHandler.ListGearRepairs)
|
||||
inventory.DELETE("/gear/:id/repairs/:repairId", gearHandler.DeleteGearRepair)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ import (
|
|||
|
||||
// GearHandler handles gear inventory HTTP requests
|
||||
type GearHandler struct {
|
||||
gearService *services.GearService
|
||||
logger *zap.Logger
|
||||
gearService *services.GearService
|
||||
gearDocService *services.GearDocumentService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewGearHandler creates a new GearHandler
|
||||
|
|
@ -24,6 +25,11 @@ func NewGearHandler(gearService *services.GearService, logger *zap.Logger) *Gear
|
|||
return &GearHandler{gearService: gearService, logger: logger}
|
||||
}
|
||||
|
||||
// SetGearDocumentService injects the gear document service
|
||||
func (h *GearHandler) SetGearDocumentService(svc *services.GearDocumentService) {
|
||||
h.gearDocService = svc
|
||||
}
|
||||
|
||||
// CreateGearItemRequest represents the request body for creating a gear item
|
||||
type CreateGearItemRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
|
|
@ -40,8 +46,10 @@ type CreateGearItemRequest struct {
|
|||
Currency string `json:"currency"`
|
||||
Vendor string `json:"vendor"`
|
||||
OrderNumber string `json:"orderNumber"`
|
||||
WarrantyStart *time.Time `json:"warrantyStart"`
|
||||
WarrantyExpire *time.Time `json:"warrantyExpire"`
|
||||
WarrantyType string `json:"warrantyType"`
|
||||
WarrantyNotes string `json:"warrantyNotes"`
|
||||
SupportContact string `json:"supportContact"`
|
||||
Specs map[string]interface{} `json:"specs"`
|
||||
Notes string `json:"notes"`
|
||||
|
|
@ -65,8 +73,10 @@ type UpdateGearItemRequest struct {
|
|||
Currency *string `json:"currency"`
|
||||
Vendor *string `json:"vendor"`
|
||||
OrderNumber *string `json:"orderNumber"`
|
||||
WarrantyStart *time.Time `json:"warrantyStart"`
|
||||
WarrantyExpire *time.Time `json:"warrantyExpire"`
|
||||
WarrantyType *string `json:"warrantyType"`
|
||||
WarrantyNotes *string `json:"warrantyNotes"`
|
||||
SupportContact *string `json:"supportContact"`
|
||||
Specs map[string]interface{} `json:"specs"`
|
||||
Notes *string `json:"notes"`
|
||||
|
|
@ -115,12 +125,18 @@ func reqToModel(req *CreateGearItemRequest) *models.GearItem {
|
|||
if req.OrderNumber != "" {
|
||||
item.OrderNumber = req.OrderNumber
|
||||
}
|
||||
if req.WarrantyStart != nil {
|
||||
item.WarrantyStart = req.WarrantyStart
|
||||
}
|
||||
if req.WarrantyExpire != nil {
|
||||
item.WarrantyExpire = req.WarrantyExpire
|
||||
}
|
||||
if req.WarrantyType != "" {
|
||||
item.WarrantyType = req.WarrantyType
|
||||
}
|
||||
if req.WarrantyNotes != "" {
|
||||
item.WarrantyNotes = req.WarrantyNotes
|
||||
}
|
||||
if req.SupportContact != "" {
|
||||
item.SupportContact = req.SupportContact
|
||||
}
|
||||
|
|
@ -170,12 +186,18 @@ func applyUpdate(item *models.GearItem, req *UpdateGearItemRequest) {
|
|||
if req.OrderNumber != nil {
|
||||
item.OrderNumber = *req.OrderNumber
|
||||
}
|
||||
if req.WarrantyStart != nil {
|
||||
item.WarrantyStart = req.WarrantyStart
|
||||
}
|
||||
if req.WarrantyExpire != nil {
|
||||
item.WarrantyExpire = req.WarrantyExpire
|
||||
}
|
||||
if req.WarrantyType != nil {
|
||||
item.WarrantyType = *req.WarrantyType
|
||||
}
|
||||
if req.WarrantyNotes != nil {
|
||||
item.WarrantyNotes = *req.WarrantyNotes
|
||||
}
|
||||
if req.SupportContact != nil {
|
||||
item.SupportContact = *req.SupportContact
|
||||
}
|
||||
|
|
@ -366,6 +388,187 @@ func (h *GearHandler) DeleteGearImage(c *gin.Context) {
|
|||
RespondSuccess(c, http.StatusOK, gin.H{"message": "image deleted"})
|
||||
}
|
||||
|
||||
// ListGearDocuments returns documents for a gear item
|
||||
func (h *GearHandler) ListGearDocuments(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
docs, err := h.gearDocService.ListDocuments(c.Request.Context(), userID, gearID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to list documents", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"documents": docs})
|
||||
}
|
||||
|
||||
// UploadGearDocument uploads a document for a gear item
|
||||
func (h *GearHandler) UploadGearDocument(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("file is required"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
docType := c.PostForm("type")
|
||||
if docType == "" {
|
||||
docType = "invoice"
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
doc, err := h.gearDocService.CreateDocument(c.Request.Context(), userID, gearID, file, header.Filename, docType)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to upload document", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"document": doc})
|
||||
}
|
||||
|
||||
// DeleteGearDocument deletes a document
|
||||
func (h *GearHandler) DeleteGearDocument(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
docID, err := uuid.Parse(c.Param("docId"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid document id"))
|
||||
return
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
if err := h.gearDocService.DeleteDocument(c.Request.Context(), userID, gearID, docID); err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete document", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "document deleted"})
|
||||
}
|
||||
|
||||
// ListGearRepairs returns repairs for a gear item
|
||||
func (h *GearHandler) ListGearRepairs(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
repairs, err := h.gearDocService.ListRepairs(c.Request.Context(), userID, gearID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to list repairs", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"repairs": repairs})
|
||||
}
|
||||
|
||||
// CreateGearRepair adds a repair record
|
||||
func (h *GearHandler) CreateGearRepair(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RepairDate string `json:"repair_date" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
CostCents int `json:"cost_cents"`
|
||||
Currency string `json:"currency"`
|
||||
Provider string `json:"provider"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("repair_date is required"))
|
||||
return
|
||||
}
|
||||
repairDate, err := time.Parse("2006-01-02", req.RepairDate)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("repair_date must be YYYY-MM-DD"))
|
||||
return
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
repair := &models.GearRepair{
|
||||
RepairDate: repairDate,
|
||||
Description: req.Description,
|
||||
CostCents: req.CostCents,
|
||||
Currency: req.Currency,
|
||||
Provider: req.Provider,
|
||||
Notes: req.Notes,
|
||||
}
|
||||
created, err := h.gearDocService.CreateRepair(c.Request.Context(), userID, gearID, repair)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to create repair", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusCreated, gin.H{"repair": created})
|
||||
}
|
||||
|
||||
// DeleteGearRepair deletes a repair record
|
||||
func (h *GearHandler) DeleteGearRepair(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
repairID, err := uuid.Parse(c.Param("repairId"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid repair id"))
|
||||
return
|
||||
}
|
||||
if h.gearDocService == nil {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "document service not configured"))
|
||||
return
|
||||
}
|
||||
if err := h.gearDocService.DeleteRepair(c.Request.Context(), userID, gearID, repairID); err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete repair", err))
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "repair deleted"})
|
||||
}
|
||||
|
||||
func (h *GearHandler) commonHandler() *CommonHandler {
|
||||
return NewCommonHandler(h.logger)
|
||||
}
|
||||
|
|
|
|||
46
veza-backend-api/internal/handlers/tag_handler_test.go
Normal file
46
veza-backend-api/internal/handlers/tag_handler_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
func TestTagHandler_Suggest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("sqlite not available: %v", err)
|
||||
return
|
||||
}
|
||||
// TagSuggestService uses PostgreSQL unnest - will fail on SQLite, but we test handler wiring
|
||||
tagService := services.NewTagSuggestService(db)
|
||||
handler := NewTagHandler(tagService)
|
||||
router := gin.New()
|
||||
router.GET("/tags/suggest", handler.Suggest)
|
||||
|
||||
t.Run("accepts request and returns JSON", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tags/suggest?q=hip&limit=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// May return 500 due to SQLite not supporting unnest, but not 404
|
||||
assert.NotEqual(t, http.StatusNotFound, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("Content-Type"), "application/json")
|
||||
})
|
||||
|
||||
t.Run("empty query", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tags/suggest?q=", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.NotEqual(t, http.StatusNotFound, rec.Code)
|
||||
})
|
||||
}
|
||||
|
|
@ -25,8 +25,10 @@ type GearItem struct {
|
|||
Currency string `gorm:"size:3;default:'USD'" json:"currency" db:"currency"`
|
||||
Vendor string `gorm:"size:200" json:"vendor" db:"vendor"`
|
||||
OrderNumber string `gorm:"size:100" json:"orderNumber" db:"order_number"`
|
||||
WarrantyStart *time.Time `json:"warrantyStart" db:"warranty_start"`
|
||||
WarrantyExpire *time.Time `json:"warrantyExpire" db:"warranty_expire"`
|
||||
WarrantyType string `gorm:"size:50" json:"warrantyType" db:"warranty_type"`
|
||||
WarrantyNotes string `gorm:"type:text" json:"warrantyNotes" db:"warranty_notes"`
|
||||
SupportContact string `gorm:"size:200" json:"supportContact" db:"support_contact"`
|
||||
Specs map[string]interface{} `gorm:"type:jsonb;default:'{}'" json:"specs" db:"specs"`
|
||||
Notes string `gorm:"type:text" json:"notes" db:"notes"`
|
||||
|
|
|
|||
31
veza-backend-api/internal/models/gear_document.go
Normal file
31
veza-backend-api/internal/models/gear_document.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GearDocument stores a document (invoice, manual, etc.) for a gear item
|
||||
type GearDocument struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
GearID uuid.UUID `gorm:"type:uuid;not null" json:"gear_id"`
|
||||
Type string `gorm:"size:50;not null;default:'invoice'" json:"type"`
|
||||
StorageKey string `gorm:"type:text;not null" json:"storage_key"`
|
||||
Filename string `gorm:"size:255;not null" json:"filename"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"`
|
||||
|
||||
Gear *GearItem `gorm:"foreignKey:GearID;constraint:OnDelete:CASCADE" json:"-"`
|
||||
}
|
||||
|
||||
func (GearDocument) TableName() string {
|
||||
return "gear_documents"
|
||||
}
|
||||
|
||||
func (m *GearDocument) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
veza-backend-api/internal/models/gear_repair.go
Normal file
34
veza-backend-api/internal/models/gear_repair.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GearRepair stores a repair record for a gear item
|
||||
type GearRepair struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
GearID uuid.UUID `gorm:"type:uuid;not null" json:"gear_id"`
|
||||
RepairDate time.Time `gorm:"type:date;not null" json:"repair_date"`
|
||||
Description string `gorm:"type:text;not null" json:"description"`
|
||||
CostCents int `gorm:"not null;default:0" json:"cost_cents"`
|
||||
Currency string `gorm:"size:3;not null;default:'EUR'" json:"currency"`
|
||||
Provider string `gorm:"size:255" json:"provider"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
Gear *GearItem `gorm:"foreignKey:GearID;constraint:OnDelete:CASCADE" json:"-"`
|
||||
}
|
||||
|
||||
func (GearRepair) TableName() string {
|
||||
return "gear_repairs"
|
||||
}
|
||||
|
||||
func (m *GearRepair) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
164
veza-backend-api/internal/services/gear_document_service.go
Normal file
164
veza-backend-api/internal/services/gear_document_service.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
// GearDocumentService handles gear document and repair operations
|
||||
type GearDocumentService struct {
|
||||
db *gorm.DB
|
||||
s3Service *S3StorageService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewGearDocumentService creates a new gear document service
|
||||
func NewGearDocumentService(db *gorm.DB, s3Service *S3StorageService, logger *zap.Logger) *GearDocumentService {
|
||||
return &GearDocumentService{db: db, s3Service: s3Service, logger: logger}
|
||||
}
|
||||
|
||||
// CreateDocument uploads a document for a gear item
|
||||
func (s *GearDocumentService) CreateDocument(ctx context.Context, userID, gearID uuid.UUID, file io.Reader, filename, docType string) (*models.GearDocument, error) {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if docType == "" {
|
||||
docType = "invoice"
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
storageKey := fmt.Sprintf("gear/%s/documents/%s/%s", gearID, uuid.New(), sanitizeGearFilename(filename))
|
||||
if s.s3Service == nil {
|
||||
return nil, fmt.Errorf("S3 storage not configured")
|
||||
}
|
||||
contentType := "application/pdf"
|
||||
if strings.HasSuffix(strings.ToLower(filename), ".png") || strings.HasSuffix(strings.ToLower(filename), ".jpg") {
|
||||
contentType = "image/" + strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
|
||||
}
|
||||
if _, err := s.s3Service.UploadFile(ctx, data, storageKey, contentType); err != nil {
|
||||
return nil, fmt.Errorf("upload to S3: %w", err)
|
||||
}
|
||||
|
||||
doc := &models.GearDocument{
|
||||
GearID: gearID,
|
||||
Type: docType,
|
||||
StorageKey: storageKey,
|
||||
Filename: filename,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(doc).Error; err != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, storageKey)
|
||||
return nil, fmt.Errorf("create document: %w", err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// ListDocuments returns documents for a gear item
|
||||
func (s *GearDocumentService) ListDocuments(ctx context.Context, userID, gearID uuid.UUID) ([]models.GearDocument, error) {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var docs []models.GearDocument
|
||||
if err := s.db.WithContext(ctx).Where("gear_id = ?", gearID).Order("uploaded_at DESC").Find(&docs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// DeleteDocument removes a document
|
||||
func (s *GearDocumentService) DeleteDocument(ctx context.Context, userID, gearID, docID uuid.UUID) error {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var doc models.GearDocument
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND gear_id = ?", docID, gearID).First(&doc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Delete(&doc).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if s.s3Service != nil {
|
||||
_ = s.s3Service.DeleteFile(ctx, doc.StorageKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRepair adds a repair record
|
||||
func (s *GearDocumentService) CreateRepair(ctx context.Context, userID, gearID uuid.UUID, repair *models.GearRepair) (*models.GearRepair, error) {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repair.GearID = gearID
|
||||
if repair.Currency == "" {
|
||||
repair.Currency = "EUR"
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(repair).Error; err != nil {
|
||||
return nil, fmt.Errorf("create repair: %w", err)
|
||||
}
|
||||
return repair, nil
|
||||
}
|
||||
|
||||
// ListRepairs returns repairs for a gear item
|
||||
func (s *GearDocumentService) ListRepairs(ctx context.Context, userID, gearID uuid.UUID) ([]models.GearRepair, error) {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var repairs []models.GearRepair
|
||||
if err := s.db.WithContext(ctx).Where("gear_id = ?", gearID).Order("repair_date DESC").Find(&repairs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repairs, nil
|
||||
}
|
||||
|
||||
// DeleteRepair removes a repair record
|
||||
func (s *GearDocumentService) DeleteRepair(ctx context.Context, userID, gearID, repairID uuid.UUID) error {
|
||||
if err := s.verifyGearOwnership(ctx, gearID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Where("id = ? AND gear_id = ?", repairID, gearID).Delete(&models.GearRepair{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("repair not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GearDocumentService) verifyGearOwnership(ctx context.Context, gearID, userID uuid.UUID) error {
|
||||
var count int64
|
||||
s.db.WithContext(ctx).Model(&models.GearItem{}).Where("id = ? AND user_id = ?", gearID, userID).Count(&count)
|
||||
if count == 0 {
|
||||
return fmt.Errorf("gear not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeGearFilename(name string) string {
|
||||
base := filepath.Base(name)
|
||||
return strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, base)
|
||||
}
|
||||
86
veza-backend-api/internal/services/gear_warranty_notifier.go
Normal file
86
veza-backend-api/internal/services/gear_warranty_notifier.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
// GearWarrantyNotifier sends notifications when gear warranty is expiring
|
||||
type GearWarrantyNotifier struct {
|
||||
db *gorm.DB
|
||||
notificationService *NotificationService
|
||||
logger *zap.Logger
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewGearWarrantyNotifier creates a new warranty notifier
|
||||
func NewGearWarrantyNotifier(db *gorm.DB, notificationService *NotificationService, logger *zap.Logger) *GearWarrantyNotifier {
|
||||
return &GearWarrantyNotifier{
|
||||
db: db,
|
||||
notificationService: notificationService,
|
||||
logger: logger,
|
||||
interval: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the notifier loop
|
||||
func (n *GearWarrantyNotifier) Start(ctx context.Context) {
|
||||
if n.notificationService == nil {
|
||||
n.logger.Info("Gear warranty notifier: notification service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(n.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
n.logger.Info("Gear warranty notifier started", zap.Duration("interval", n.interval))
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := n.checkAndNotify(ctx); err != nil {
|
||||
n.logger.Error("Gear warranty notifier failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *GearWarrantyNotifier) checkAndNotify(ctx context.Context) error {
|
||||
// Gear with warranty_expire between now and now+30 days
|
||||
now := time.Now()
|
||||
expiryLimit := now.Add(30 * 24 * time.Hour)
|
||||
|
||||
var items []models.GearItem
|
||||
err := n.db.WithContext(ctx).Where(
|
||||
"warranty_expire IS NOT NULL AND warranty_expire > ? AND warranty_expire <= ?",
|
||||
now, expiryLimit,
|
||||
).Find(&items).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.WarrantyExpire == nil {
|
||||
continue
|
||||
}
|
||||
daysLeft := int(item.WarrantyExpire.Sub(now).Hours() / 24)
|
||||
msg := fmt.Sprintf("%s warranty expires in %d days", item.Name, daysLeft)
|
||||
link := fmt.Sprintf("/inventory?gear=%s", item.ID)
|
||||
if err := n.notificationService.CreateNotification(item.UserID, "gear_warranty", "Warranty expiring soon", msg, link); err != nil {
|
||||
n.logger.Warn("failed to send warranty notification", zap.String("gear_id", item.ID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) > 0 {
|
||||
n.logger.Info("Gear warranty notifications sent", zap.Int("count", len(items)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Reference in a new issue