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/') && ( + + ))} + + )} +
+ ); + + return ( + + {content} + + ); +} diff --git a/apps/web/src/features/cloud/components/CloudShareModal.stories.tsx b/apps/web/src/features/cloud/components/CloudShareModal.stories.tsx new file mode 100644 index 000000000..ce822409e --- /dev/null +++ b/apps/web/src/features/cloud/components/CloudShareModal.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { CloudShareModal } from './CloudShareModal'; + +const meta: Meta = { + title: 'Cloud/CloudShareModal', + component: CloudShareModal, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + fileId: 'c1000000-0000-0000-0000-000000000001', + filename: 'sunset-beat.mp3', + isOpen: true, + onClose: () => {}, + }, +}; diff --git a/apps/web/src/features/cloud/components/CloudShareModal.tsx b/apps/web/src/features/cloud/components/CloudShareModal.tsx new file mode 100644 index 000000000..5926332da --- /dev/null +++ b/apps/web/src/features/cloud/components/CloudShareModal.tsx @@ -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(null); + const [expiresAt, setExpiresAt] = useState(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 ( + +
+

{filename}

+ + {!shareUrl ? ( + <> +
+ + +
+ + + ) : ( +
+
+ +
+ + +
+
+ {expiresAt && ( +

+ Expires: {new Date(expiresAt).toLocaleString()} +

+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx b/apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx index 90d2484a6..daf3ce175 100644 --- a/apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx +++ b/apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx @@ -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(null); + const [versionsFile, setVersionsFile] = useState(null); + const [shareFile, setShareFile] = useState(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} /> @@ -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 */} +
+ + + +
+ {/* 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

+
+ + +
+
+ + {documents.length === 0 ? ( +

No documents yet

+ ) : ( +
    + {documents.map((doc) => ( +
  • +
    + +
    +

    {doc.filename}

    +

    {formatDate(doc.uploaded_at)} · {doc.type}

    +
    +
    + +
  • + ))} +
+ )} +
+ ); +} 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

+ +
+ + {showForm && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + 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" + /> +
+
+ + setForm((f) => ({ ...f, currency: e.target.value }))} + className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground" + /> +
+
+
+ + 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" + /> +
+
+ + +
+
+ )} + + {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}

    + )} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} 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 +}