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:
senke 2026-02-25 14:00:58 +01:00
parent 596233aaaf
commit 7692c4b8b9
32 changed files with 1854 additions and 36 deletions

View file

@ -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.

View file

@ -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

View file

@ -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); }}

View file

@ -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: [] })
),
],
},
},
};

View 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>
);
}

View file

@ -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: () => {},
},
};

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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),
},
},
};

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 };
}

View file

@ -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 });
}),

View file

@ -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');

View file

@ -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;
}

View file

@ -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:

View file

@ -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 |

View file

@ -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 (103108)
- 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

View file

@ -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.

View 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 (119122) 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 dinté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 |

View file

@ -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")

View file

@ -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)
}
}

View file

@ -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)
}

View 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)
})
}

View file

@ -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"`

View 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
}

View 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
}

View 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)
}

View 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
}