From b32553668d28e969fc521197650d69598d9a235d Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 17 Feb 2026 10:04:01 -0500 Subject: [PATCH] feat: add invoice viewing (C-015) and notifications display (C-016) - Invoice page with table, search, sort, CSV export, PDF download - Company-level access control (can_access_invoices + view_invoices permission) - Notifications page with unread alerts section and mark-as-read - Fix unread notification count in layout (was counting read markers) - Fix dashboard notification count to use per-user unread alerts - Configurable INVOICE_STORAGE_PATH for invoice PDF file serving Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 + TASKS.md | 28 ++- src/app/(portal)/invoices/page.tsx | 88 ++++++++ src/app/(portal)/layout.tsx | 12 +- src/app/(portal)/notifications/page.tsx | 70 ++++++ src/app/api/dashboard/route.ts | 13 +- src/app/api/invoices/[id]/pdf/route.ts | 52 +++++ src/app/api/invoices/route.ts | 36 +++ src/app/api/notifications/[id]/read/route.ts | 24 ++ src/app/api/notifications/route.ts | 21 ++ src/components/invoices/invoice-table.tsx | 208 ++++++++++++++++++ .../notifications/notification-list.tsx | 188 ++++++++++++++++ src/lib/storage.ts | 10 + src/services/invoices.ts | 68 ++++++ src/services/notifications.ts | 74 +++++++ src/types/invoices.ts | 9 + src/types/notifications.ts | 9 + 17 files changed, 881 insertions(+), 32 deletions(-) create mode 100644 src/app/(portal)/invoices/page.tsx create mode 100644 src/app/(portal)/notifications/page.tsx create mode 100644 src/app/api/invoices/[id]/pdf/route.ts create mode 100644 src/app/api/invoices/route.ts create mode 100644 src/app/api/notifications/[id]/read/route.ts create mode 100644 src/app/api/notifications/route.ts create mode 100644 src/components/invoices/invoice-table.tsx create mode 100644 src/components/notifications/notification-list.tsx create mode 100644 src/lib/storage.ts create mode 100644 src/services/invoices.ts create mode 100644 src/services/notifications.ts create mode 100644 src/types/invoices.ts create mode 100644 src/types/notifications.ts diff --git a/.env.example b/.env.example index c994db7..a31d145 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ TRAEFIK_DOMAIN=traefik.vorteq.wulf.cloud DEV_AUTH_SECRET=your-dev-secret-here TEST_AUTH_SECRET=your-test-secret-here PROD_AUTH_SECRET=your-prod-secret-here + +# Invoice PDF Storage Path (where uploaded invoice PDFs are stored) +INVOICE_STORAGE_PATH=./storage/invoices diff --git a/TASKS.md b/TASKS.md index 017f7f9..5c3c505 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,7 @@ ## Phase 2: Core Features (Est. 80-110 hrs) -**Progress:** 12/16 tasks complete +**Progress:** 14/16 tasks complete ### C-001: Dashboard - [x] Dashboard page at `/(portal)/dashboard/page.tsx` @@ -282,19 +282,23 @@ - **Deps:** F-005, C-003 | **Est:** 8 hrs ### C-015: Invoice Viewing (Customer) -- [ ] `/(portal)/invoices/page.tsx` -- [ ] Only visible if company `CanAccessInvoices=true` -- [ ] List invoices from `inv_upload_entry` filtered by company -- [ ] PDF download endpoint `/api/invoices/:id/pdf` -- [ ] File serving from storage directory -- **Deps:** F-005, F-009 | **Est:** 4 hrs +- [x] `/(portal)/invoices/page.tsx` — client-side fetch with loading skeleton +- [x] Only visible if company `can_access_invoices=true` (403 response + user-friendly message) +- [x] Service: `getInvoicesForCompany(companyId)` querying `inv_upload_entry` filtered by company +- [x] Invoice table with search, sort, CSV export (invoice#, job#, page, date, sent date, PDF link) +- [x] PDF download endpoint `/api/invoices/{id}/pdf` with company security check +- [x] File serving from configurable `INVOICE_STORAGE_PATH` storage directory +- [x] Permission check: `view_invoices` + `can_access_invoices` +- **Deps:** F-005, F-009 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-016: Notifications Display -- [ ] Notification bell in header with unread count -- [ ] Alert banner on dashboard for IsAlert=true notifications -- [ ] Notification list view -- [ ] Mark as read → create `quest_user_notification_alert_read` record -- **Deps:** F-005, F-009 | **Est:** 3 hrs +- [x] Fixed notification bell unread count (was counting read records, now counts unread alerts) +- [x] Notification service: `getActiveNotifications()`, `getUnreadAlertCount()`, `markNotificationRead()` +- [x] Alert banner on dashboard uses correct unread count for current user +- [x] `/(portal)/notifications/page.tsx` — notification list with unread alerts section +- [x] Mark as read → upsert `quest_user_notification_alert_read` record +- [x] API routes: `/api/notifications` (GET list) + `/api/notifications/{id}/read` (POST mark read) +- **Deps:** F-005, F-009 | **Est:** 3 hrs | **Status:** ✅ Complete --- diff --git a/src/app/(portal)/invoices/page.tsx b/src/app/(portal)/invoices/page.tsx new file mode 100644 index 0000000..e9e13c2 --- /dev/null +++ b/src/app/(portal)/invoices/page.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { InvoiceTable } from '@/components/invoices/invoice-table'; +import { FileText } from 'lucide-react'; +import type { InvoiceEntry } from '@/types/invoices'; + +function InvoiceSkeleton() { + return ( + + +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ + + ); +} + +export default function InvoicesPage() { + const [invoices, setInvoices] = useState(null); + const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); + + useEffect(() => { + fetch('/api/invoices') + .then((res) => { + if (res.status === 403) { + setAccessDenied(true); + return []; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json) => { + if (!accessDenied) setInvoices(json); + }) + .catch((err) => setError(err.message)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (accessDenied) { + return ( +
+

Invoices

+ + + +

+ Invoice access is not available +

+

+ Your company does not have invoice viewing enabled. Please contact + Vorteq support if you need access to invoices. +

+
+
+
+ ); + } + + return ( +
+

Invoices

+ {error ? ( + + + Failed to load invoices: {error} + + + ) : invoices === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx index 578f5a1..5397767 100644 --- a/src/app/(portal)/layout.tsx +++ b/src/app/(portal)/layout.tsx @@ -7,7 +7,7 @@ import { PortalSidebar } from '@/components/layout/portal-sidebar'; import { PortalHeader } from '@/components/layout/portal-header'; import { Breadcrumb } from '@/components/layout/breadcrumb'; import { redirect } from 'next/navigation'; -import { db } from '@/lib/db'; +import { getUnreadAlertCount } from '@/services/notifications'; export default async function PortalLayout({ children, @@ -24,14 +24,8 @@ export default async function PortalLayout({ // Get active company const activeCompany = await getActiveCompany(); - // Get unread notifications count - const unreadNotifications = await db.quest_user_notification_alert_read.count( - { - where: { - auth_user_id: session.user.id, - }, - } - ); + // Get unread alert notifications count (active alerts not yet read by this user) + const unreadNotifications = await getUnreadAlertCount(session.user.id); const isAdmin = session.userType === 'Admin' || session.userType === 'Super Admin'; diff --git a/src/app/(portal)/notifications/page.tsx b/src/app/(portal)/notifications/page.tsx new file mode 100644 index 0000000..11eaec1 --- /dev/null +++ b/src/app/(portal)/notifications/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { NotificationList } from '@/components/notifications/notification-list'; +import type { NotificationItem } from '@/types/notifications'; + +function NotificationsSkeleton() { + return ( +
+ {[...Array(3)].map((_, i) => ( + + +
+
+
+
+
+ + + ))} +
+ ); +} + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState( + null + ); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/notifications') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json) => setNotifications(json)) + .catch((err) => setError(err.message)); + }, []); + + const handleMarkRead = async (id: string) => { + const res = await fetch(`/api/notifications/${id}/read`, { + method: 'POST', + }); + if (!res.ok) { + throw new Error('Failed to mark notification as read'); + } + }; + + return ( +
+

Notifications

+ {error ? ( + + + Failed to load notifications: {error} + + + ) : notifications === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts index 60da06b..876330c 100644 --- a/src/app/api/dashboard/route.ts +++ b/src/app/api/dashboard/route.ts @@ -5,7 +5,7 @@ import { getInventorySummary, } from '@/services/dashboard'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; -import { db } from '@/lib/db'; +import { getUnreadAlertCount } from '@/services/notifications'; export const dynamic = 'force-dynamic'; @@ -28,16 +28,7 @@ export async function GET() { unprocessed_count: 0, total_weight: 0, })), - db.quest_notification - .count({ - where: { - is_alert: true, - created_at: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - }, - }, - }) - .catch(() => 0), + getUnreadAlertCount(session.user.id).catch(() => 0), ]); return NextResponse.json({ diff --git a/src/app/api/invoices/[id]/pdf/route.ts b/src/app/api/invoices/[id]/pdf/route.ts new file mode 100644 index 0000000..736767a --- /dev/null +++ b/src/app/api/invoices/[id]/pdf/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getInvoiceFile } from '@/services/invoices'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + if (!activeCompany.can_access_invoices) { + return NextResponse.json( + { error: 'Invoice access is not enabled for this company' }, + { status: 403 } + ); + } + + try { + await requirePermission('view_invoices'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + + try { + const file = await getInvoiceFile(id, activeCompany.id); + + if (!file) { + return NextResponse.json( + { error: 'Invoice file not found' }, + { status: 404 } + ); + } + + return new NextResponse(new Uint8Array(file.buffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${file.filename}"`, + 'Cache-Control': 'private, max-age=3600', + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/invoices/route.ts b/src/app/api/invoices/route.ts new file mode 100644 index 0000000..f539e5e --- /dev/null +++ b/src/app/api/invoices/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getInvoicesForCompany } from '@/services/invoices'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + // Check company-level access + if (!activeCompany.can_access_invoices) { + return NextResponse.json( + { error: 'Invoice access is not enabled for this company' }, + { status: 403 } + ); + } + + try { + await requirePermission('view_invoices'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const invoices = await getInvoicesForCompany(activeCompany.id); + return NextResponse.json(invoices); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/[id]/read/route.ts b/src/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..2fefa7c --- /dev/null +++ b/src/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession } from '@/lib/permissions'; +import { markNotificationRead } from '@/services/notifications'; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + try { + await markNotificationRead(id, session.user.id); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..17658f1 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession } from '@/lib/permissions'; +import { getActiveNotifications } from '@/services/notifications'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const notifications = await getActiveNotifications(session.user.id); + return NextResponse.json(notifications); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/invoices/invoice-table.tsx b/src/components/invoices/invoice-table.tsx new file mode 100644 index 0000000..ac332a2 --- /dev/null +++ b/src/components/invoices/invoice-table.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState } from 'react'; +import type { InvoiceEntry } from '@/types/invoices'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui/table'; +import { Download, FileText, Search } from 'lucide-react'; +import { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: InvoiceEntry[]; +}; + +export function InvoiceTable({ data }: Props) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = data.filter((row) => { + const s = searchTerm.toLowerCase(); + return ( + row.invoice_number.toLowerCase().includes(s) || + (row.job_number || '').toLowerCase().includes(s) + ); + }); + + const { sortKey, sortDirection, handleSort, sortedData } = + useSortableTable(filteredData); + + const handleExportCSV = () => { + const headers = [ + 'Invoice #', + 'Job #', + 'Page', + 'Date', + 'Sent Date', + ]; + const rows = sortedData.map((row) => [ + row.invoice_number, + row.job_number || '', + row.page_number ?? '', + row.created_at ? new Date(row.created_at).toLocaleDateString() : '', + row.sent_at ? new Date(row.sent_at).toLocaleDateString() : '', + ]); + const csvContent = [headers, ...rows] + .map((row) => row.map((cell) => `"${cell}"`).join(',')) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `invoices-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( + + + Invoices + + Showing {sortedData.length} invoice{sortedData.length !== 1 ? 's' : ''} + + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ +
+ + + + + Invoice # + + + Job # + + + Page + + + Date + + + Sent Date + + + + + + {sortedData.length === 0 ? ( + + + No invoices found + + + ) : ( + sortedData.map((row, i) => ( + + + {row.invoice_number} + + {row.job_number || '-'} + + {row.page_number ?? '-'} + + + {row.created_at + ? formatDate(new Date(row.created_at)) + : '-'} + + + {row.sent_at + ? formatDate(new Date(row.sent_at)) + : '-'} + + + {row.has_file ? ( + + + + ) : ( + - + )} + + + )) + )} + +
+ PDF +
+
+ +
+ Showing {sortedData.length} of {data.length} invoices +
+
+
+ ); +} diff --git a/src/components/notifications/notification-list.tsx b/src/components/notifications/notification-list.tsx new file mode 100644 index 0000000..1a4a331 --- /dev/null +++ b/src/components/notifications/notification-list.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useState } from 'react'; +import type { NotificationItem } from '@/types/notifications'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { AlertTriangle, Bell, CheckCircle } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; + +type Props = { + notifications: NotificationItem[]; + onMarkRead: (id: string) => Promise; +}; + +export function NotificationList({ notifications, onMarkRead }: Props) { + const [markingRead, setMarkingRead] = useState(null); + const [readIds, setReadIds] = useState>(new Set()); + + const handleMarkRead = async (id: string) => { + setMarkingRead(id); + try { + await onMarkRead(id); + setReadIds((prev) => new Set(prev).add(id)); + } finally { + setMarkingRead(null); + } + }; + + const unreadAlerts = notifications.filter( + (n) => n.is_alert && !n.is_read && !readIds.has(n.id) + ); + const otherNotifications = notifications.filter( + (n) => !n.is_alert || n.is_read || readIds.has(n.id) + ); + + if (notifications.length === 0) { + return ( + + + +

+ No notifications +

+

+ You're all caught up! +

+
+
+ ); + } + + return ( +
+ {/* Unread Alerts Section */} + {unreadAlerts.length > 0 && ( +
+

+ + Unread Alerts ({unreadAlerts.length}) +

+
+ {unreadAlerts.map((notification) => ( + + +
+
+ + {notification.title} + + + Alert + +
+ +
+ + {formatDate(new Date(notification.display_date))} + {notification.display_time + ? ` at ${notification.display_time}` + : ''} + +
+ +

+ {notification.text} +

+
+
+ ))} +
+
+ )} + + {/* Other Notifications */} + {otherNotifications.length > 0 && ( +
+ {unreadAlerts.length > 0 && ( +

+ All Notifications +

+ )} +
+ {otherNotifications.map((notification) => { + const isRead = notification.is_read || readIds.has(notification.id); + return ( + + +
+
+ + {notification.title} + + {notification.is_alert && ( + + Alert + + )} + {isRead && ( + + Read + + )} +
+ {notification.is_alert && !isRead && ( + + )} +
+ + {formatDate(new Date(notification.display_date))} + {notification.display_time + ? ` at ${notification.display_time}` + : ''} + +
+ +

+ {notification.text} +

+
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..9e5e1f0 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +/** + * Get the base storage path for invoice PDF files. + * In production, this should point to the mounted storage volume. + * In development, falls back to a local directory. + */ +export function getInvoiceStoragePath(): string { + return process.env.INVOICE_STORAGE_PATH || path.join(process.cwd(), 'storage', 'invoices'); +} diff --git a/src/services/invoices.ts b/src/services/invoices.ts new file mode 100644 index 0000000..9fd7a29 --- /dev/null +++ b/src/services/invoices.ts @@ -0,0 +1,68 @@ +import { db } from '@/lib/db'; +import { getInvoiceStoragePath } from '@/lib/storage'; +import type { InvoiceEntry } from '@/types/invoices'; +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Get all invoices for a company. + * Filters out ignored and replaced entries. + * Returns newest first. + */ +export async function getInvoicesForCompany( + companyId: string +): Promise { + const entries = await db.inv_upload_entry.findMany({ + where: { + quest_company_id: companyId, + is_ignored: false, + is_replaced: false, + }, + orderBy: { created_at: 'desc' }, + }); + + return entries.map((e) => ({ + id: e.id, + invoice_number: e.invoice_number, + job_number: e.job_number, + page_number: e.page_number, + created_at: e.created_at.toISOString(), + sent_at: e.sent_at ? e.sent_at.toISOString() : null, + has_file: !!(e.file_path && e.storage_filename), + })); +} + +/** + * Get an invoice PDF file for download. + * Verifies the entry belongs to the specified company for security. + * Returns the file buffer and filename, or null if not found. + */ +export async function getInvoiceFile( + entryId: string, + companyId: string +): Promise<{ buffer: Buffer; filename: string } | null> { + const entry = await db.inv_upload_entry.findFirst({ + where: { + id: entryId, + quest_company_id: companyId, + is_ignored: false, + is_replaced: false, + }, + }); + + if (!entry || !entry.file_path || !entry.storage_filename) { + return null; + } + + const storagePath = getInvoiceStoragePath(); + const filePath = path.join(storagePath, entry.file_path); + + try { + const buffer = await fs.readFile(filePath); + const filename = `invoice-${entry.invoice_number}.pdf`; + return { buffer, filename }; + } catch { + // File doesn't exist on disk + return null; + } +} diff --git a/src/services/notifications.ts b/src/services/notifications.ts new file mode 100644 index 0000000..2e5bf5a --- /dev/null +++ b/src/services/notifications.ts @@ -0,0 +1,74 @@ +import { db } from '@/lib/db'; +import type { NotificationItem } from '@/types/notifications'; + +/** + * Get all active notifications with read status for a specific user. + * Returns notifications where display_date <= now, sorted newest first. + */ +export async function getActiveNotifications( + userId: string +): Promise { + const notifications = await db.quest_notification.findMany({ + where: { + is_active: true, + display_date: { lte: new Date() }, + }, + include: { + alert_reads: { + where: { auth_user_id: userId }, + select: { id: true }, + }, + }, + orderBy: { display_date: 'desc' }, + }); + + return notifications.map((n) => ({ + id: n.id, + title: n.title, + text: n.text, + is_alert: n.is_alert, + display_date: n.display_date.toISOString(), + display_time: n.display_time, + is_read: n.alert_reads.length > 0, + })); +} + +/** + * Count unread alert notifications for a user. + * Counts active alerts where display_date <= now and user has NOT read them. + */ +export async function getUnreadAlertCount(userId: string): Promise { + return db.quest_notification.count({ + where: { + is_active: true, + is_alert: true, + display_date: { lte: new Date() }, + alert_reads: { + none: { auth_user_id: userId }, + }, + }, + }); +} + +/** + * Mark a notification as read for a specific user. + * Uses upsert to avoid duplicate read records. + */ +export async function markNotificationRead( + notificationId: string, + userId: string +): Promise { + await db.quest_user_notification_alert_read.upsert({ + where: { + quest_notification_id_auth_user_id: { + quest_notification_id: notificationId, + auth_user_id: userId, + }, + }, + create: { + quest_notification_id: notificationId, + auth_user_id: userId, + }, + update: {}, + }); +} diff --git a/src/types/invoices.ts b/src/types/invoices.ts new file mode 100644 index 0000000..11ae9d6 --- /dev/null +++ b/src/types/invoices.ts @@ -0,0 +1,9 @@ +export type InvoiceEntry = { + id: string; + invoice_number: string; + job_number: string | null; + page_number: number | null; + created_at: string; + sent_at: string | null; + has_file: boolean; +}; diff --git a/src/types/notifications.ts b/src/types/notifications.ts new file mode 100644 index 0000000..5e3cc27 --- /dev/null +++ b/src/types/notifications.ts @@ -0,0 +1,9 @@ +export type NotificationItem = { + id: string; + title: string; + text: string; + is_alert: boolean; + display_date: string; + display_time: string | null; + is_read: boolean; +};