diff --git a/.cursorrules b/.cursorrules
index 690b5e555..e1d4d97a2 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 353f43d30..d543f075f 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/apps/web/src/features/cloud/components/CloudFileList.tsx b/apps/web/src/features/cloud/components/CloudFileList.tsx
index a99b295a0..6e1e2a088 100644
--- a/apps/web/src/features/cloud/components/CloudFileList.tsx
+++ b/apps/web/src/features/cloud/components/CloudFileList.tsx
@@ -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({
+ {onVersionsFile && (
+
+ )}
+ {onShareFile && (
+
+ )}
{onPublishFile && file.mime_type.startsWith('audio/') && (
@@ -115,6 +121,25 @@ export function CloudView() {
quota={quota}
onUploadComplete={handleUploadComplete}
/>
+
+ {versionsFile && (
+ setVersionsFile(null)}
+ onRestored={() => void refetchFiles()}
+ />
+ )}
+
+ {shareFile && (
+ setShareFile(null)}
+ />
+ )}
);
}
diff --git a/apps/web/src/features/cloud/services/cloudService.ts b/apps/web/src/features/cloud/services/cloudService.ts
index 0d734e12f..165316b8c 100644
--- a/apps/web/src/features/cloud/services/cloudService.ts
+++ b/apps/web/src/features/cloud/services/cloudService.ts
@@ -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 {
+ const res = await apiClient.get(`/cloud/files/${fileId}/versions`);
+ return res.data.versions ?? [];
+ },
+
+ async createVersion(fileId: string): Promise {
+ await apiClient.post(`/cloud/files/${fileId}/versions`);
+ },
+
+ async restoreVersion(fileId: string, version: number): Promise {
+ await apiClient.post(`/cloud/files/${fileId}/restore/${version}`);
+ },
+
+ async shareFile(
+ fileId: string,
+ permissions?: string,
+ expiresInHours?: number
+ ): Promise {
+ 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 {
+ 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;
+}
diff --git a/apps/web/src/features/inventory/components/gear/GearDetailModal.stories.tsx b/apps/web/src/features/inventory/components/gear/GearDetailModal.stories.tsx
index 36dbf41da..dc3e058e8 100644
--- a/apps/web/src/features/inventory/components/gear/GearDetailModal.stories.tsx
+++ b/apps/web/src/features/inventory/components/gear/GearDetailModal.stories.tsx
@@ -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),
+ },
+ },
+};
diff --git a/apps/web/src/features/inventory/components/gear/GearDetailModal.tsx b/apps/web/src/features/inventory/components/gear/GearDetailModal.tsx
index 64d4edd44..71b850982 100644
--- a/apps/web/src/features/inventory/components/gear/GearDetailModal.tsx
+++ b/apps/web/src/features/inventory/components/gear/GearDetailModal.tsx
@@ -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 (
@@ -66,9 +73,15 @@ export function GearDetailModal({
)}
-
+
{item.serialNumber ?? '—'}
+ {warranty.daysLeft != null && warranty.daysLeft >= 0 && warranty.daysLeft <= 30 && (
+
+ )}
{item.name}
@@ -125,8 +138,55 @@ export function GearDetailModal({
+ {/* Tab bar */}
+
+ 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
+
+ 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
+
+ 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
+
+
+
{/* Modal Body */}
+ {activeTab === 'documents' && (
+
+ )}
+ {activeTab === 'repairs' && (
+
+ )}
+ {activeTab === 'overview' && (
@@ -270,6 +330,7 @@ export function GearDetailModal({
+ )}
diff --git a/apps/web/src/features/inventory/components/gear/GearDocumentsTab.tsx b/apps/web/src/features/inventory/components/gear/GearDocumentsTab.tsx
new file mode 100644
index 000000000..3c70d619b
--- /dev/null
+++ b/apps/web/src/features/inventory/components/gear/GearDocumentsTab.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [uploading, setUploading] = useState(false);
+ const [deleting, setDeleting] = useState(null);
+ const inputRef = useRef(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) => {
+ 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 (
+
+
+ Loading documents...
+
+ );
+ }
+
+ return (
+
+
+
Documents
+
+
+
inputRef.current?.click()}
+ disabled={uploading}
+ >
+ {uploading ? (
+
+ ) : (
+ <>
+
+ Upload PDF
+ >
+ )}
+
+
+
+
+ {documents.length === 0 ? (
+
No documents yet
+ ) : (
+
+ {documents.map((doc) => (
+ -
+
+
+
+
{doc.filename}
+
{formatDate(doc.uploaded_at)} · {doc.type}
+
+
+ handleDelete(doc.id)}
+ disabled={deleting !== null}
+ >
+ {deleting === doc.id ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/inventory/components/gear/GearRepairsTab.tsx b/apps/web/src/features/inventory/components/gear/GearRepairsTab.tsx
new file mode 100644
index 000000000..9308cbab6
--- /dev/null
+++ b/apps/web/src/features/inventory/components/gear/GearRepairsTab.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [deleting, setDeleting] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ const [form, setForm] = useState({
+ 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 (
+
+
+ Loading repairs...
+
+ );
+ }
+
+ return (
+
+
+
Repair history
+
setShowForm(!showForm)}
+ >
+
+ Add repair
+
+
+
+ {showForm && (
+
+ )}
+
+ {repairs.length === 0 && !showForm ? (
+
No repairs recorded
+ ) : (
+
+ {repairs.map((repair) => (
+ -
+
+
+
+
{repair.description || 'Repair'}
+
+ {formatDate(repair.repair_date)}
+ {repair.provider && ` · ${repair.provider}`}
+
+ {repair.cost_cents > 0 && (
+
+ {formatCost(repair.cost_cents, repair.currency)}
+
+ )}
+ {repair.notes && (
+
{repair.notes}
+ )}
+
+
+ handleDelete(repair.id)}
+ disabled={deleting !== null}
+ >
+ {deleting === repair.id ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/inventory/components/gear/gearUtils.ts b/apps/web/src/features/inventory/components/gear/gearUtils.ts
index f561c56f3..052b45e77 100644
--- a/apps/web/src/features/inventory/components/gear/gearUtils.ts
+++ b/apps/web/src/features/inventory/components/gear/gearUtils.ts
@@ -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 };
}
diff --git a/apps/web/src/mocks/handlers-cloud.ts b/apps/web/src/mocks/handlers-cloud.ts
index 02bf78244..95f6759e7 100644
--- a/apps/web/src/mocks/handlers-cloud.ts
+++ b/apps/web/src/mocks/handlers-cloud.ts
@@ -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 });
}),
diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts
index e4a0fe426..fbdab1ae5 100644
--- a/apps/web/src/mocks/handlers-misc.ts
+++ b/apps/web/src/mocks/handlers-misc.ts
@@ -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;
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');
diff --git a/apps/web/src/services/gearService.ts b/apps/web/src/services/gearService.ts
index abd40fa4c..8975c8e77 100644
--- a/apps/web/src/services/gearService.ts
+++ b/apps/web/src/services/gearService.ts
@@ -84,4 +84,86 @@ export const gearService = {
async delete(id: string): Promise {
await apiClient.delete(`/inventory/gear/${id}`);
},
+
+ async listDocuments(gearId: string): Promise {
+ 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 {
+ 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 {
+ await apiClient.delete(`/inventory/gear/${gearId}/documents/${docId}`);
+ },
+
+ async listRepairs(gearId: string): Promise {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index 9772b59ef..3ec25bb80 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -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:
diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md
index 53586cf83..23bd258fb 100644
--- a/docs/FEATURE_STATUS.md
+++ b/docs/FEATURE_STATUS.md
@@ -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 |
diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md
index 431a5d740..f5b414371 100644
--- a/docs/PROJECT_STATE.md
+++ b/docs/PROJECT_STATE.md
@@ -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
diff --git a/docs/SCOPE_CONTROL.md b/docs/SCOPE_CONTROL.md
index 9f73e6748..c3bd4c466 100644
--- a/docs/SCOPE_CONTROL.md
+++ b/docs/SCOPE_CONTROL.md
@@ -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.
diff --git a/docs/archive/RETROSPECTIVE_V0802.md b/docs/archive/RETROSPECTIVE_V0802.md
new file mode 100644
index 000000000..38d4070da
--- /dev/null
+++ b/docs/archive/RETROSPECTIVE_V0802.md
@@ -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 |
diff --git a/docs/V0_802_RELEASE_SCOPE.md b/docs/archive/V0_802_RELEASE_SCOPE.md
similarity index 100%
rename from docs/V0_802_RELEASE_SCOPE.md
rename to docs/archive/V0_802_RELEASE_SCOPE.md
diff --git a/veza-backend-api/cmd/api/main.go b/veza-backend-api/cmd/api/main.go
index 365970325..4c9b89598 100644
--- a/veza-backend-api/cmd/api/main.go
+++ b/veza-backend-api/cmd/api/main.go
@@ -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")
diff --git a/veza-backend-api/internal/api/routes_gear.go b/veza-backend-api/internal/api/routes_gear.go
index ff9b6443c..b3a5745ff 100644
--- a/veza-backend-api/internal/api/routes_gear.go
+++ b/veza-backend-api/internal/api/routes_gear.go
@@ -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)
}
}
diff --git a/veza-backend-api/internal/handlers/gear_handler.go b/veza-backend-api/internal/handlers/gear_handler.go
index abb1387c0..c24a402fe 100644
--- a/veza-backend-api/internal/handlers/gear_handler.go
+++ b/veza-backend-api/internal/handlers/gear_handler.go
@@ -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)
}
diff --git a/veza-backend-api/internal/handlers/tag_handler_test.go b/veza-backend-api/internal/handlers/tag_handler_test.go
new file mode 100644
index 000000000..85b5467d6
--- /dev/null
+++ b/veza-backend-api/internal/handlers/tag_handler_test.go
@@ -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)
+ })
+}
diff --git a/veza-backend-api/internal/models/gear.go b/veza-backend-api/internal/models/gear.go
index 8f83b0fa6..00ee3b297 100644
--- a/veza-backend-api/internal/models/gear.go
+++ b/veza-backend-api/internal/models/gear.go
@@ -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"`
diff --git a/veza-backend-api/internal/models/gear_document.go b/veza-backend-api/internal/models/gear_document.go
new file mode 100644
index 000000000..eea28fa39
--- /dev/null
+++ b/veza-backend-api/internal/models/gear_document.go
@@ -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
+}
diff --git a/veza-backend-api/internal/models/gear_repair.go b/veza-backend-api/internal/models/gear_repair.go
new file mode 100644
index 000000000..f2001b312
--- /dev/null
+++ b/veza-backend-api/internal/models/gear_repair.go
@@ -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
+}
diff --git a/veza-backend-api/internal/services/gear_document_service.go b/veza-backend-api/internal/services/gear_document_service.go
new file mode 100644
index 000000000..6cb1d9e58
--- /dev/null
+++ b/veza-backend-api/internal/services/gear_document_service.go
@@ -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)
+}
diff --git a/veza-backend-api/internal/services/gear_warranty_notifier.go b/veza-backend-api/internal/services/gear_warranty_notifier.go
new file mode 100644
index 000000000..357b2be1d
--- /dev/null
+++ b/veza-backend-api/internal/services/gear_warranty_notifier.go
@@ -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
+}