feat: add invoice viewing (C-015) and notifications display (C-016)
Some checks failed
Build and Deploy / build (push) Successful in 5m4s
Build and Deploy / deploy (push) Failing after 3s

- 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 <noreply@anthropic.com>
This commit is contained in:
Lorentz Hinrichsen 2026-02-17 10:04:01 -05:00
parent 1198aaa373
commit b32553668d
17 changed files with 881 additions and 32 deletions

View file

@ -15,3 +15,6 @@ TRAEFIK_DOMAIN=traefik.vorteq.wulf.cloud
DEV_AUTH_SECRET=your-dev-secret-here DEV_AUTH_SECRET=your-dev-secret-here
TEST_AUTH_SECRET=your-test-secret-here TEST_AUTH_SECRET=your-test-secret-here
PROD_AUTH_SECRET=your-prod-secret-here PROD_AUTH_SECRET=your-prod-secret-here
# Invoice PDF Storage Path (where uploaded invoice PDFs are stored)
INVOICE_STORAGE_PATH=./storage/invoices

View file

@ -128,7 +128,7 @@
## Phase 2: Core Features (Est. 80-110 hrs) ## Phase 2: Core Features (Est. 80-110 hrs)
**Progress:** 12/16 tasks complete **Progress:** 14/16 tasks complete
### C-001: Dashboard ### C-001: Dashboard
- [x] Dashboard page at `/(portal)/dashboard/page.tsx` - [x] Dashboard page at `/(portal)/dashboard/page.tsx`
@ -282,19 +282,23 @@
- **Deps:** F-005, C-003 | **Est:** 8 hrs - **Deps:** F-005, C-003 | **Est:** 8 hrs
### C-015: Invoice Viewing (Customer) ### C-015: Invoice Viewing (Customer)
- [ ] `/(portal)/invoices/page.tsx` - [x] `/(portal)/invoices/page.tsx` — client-side fetch with loading skeleton
- [ ] Only visible if company `CanAccessInvoices=true` - [x] Only visible if company `can_access_invoices=true` (403 response + user-friendly message)
- [ ] List invoices from `inv_upload_entry` filtered by company - [x] Service: `getInvoicesForCompany(companyId)` querying `inv_upload_entry` filtered by company
- [ ] PDF download endpoint `/api/invoices/:id/pdf` - [x] Invoice table with search, sort, CSV export (invoice#, job#, page, date, sent date, PDF link)
- [ ] File serving from storage directory - [x] PDF download endpoint `/api/invoices/{id}/pdf` with company security check
- **Deps:** F-005, F-009 | **Est:** 4 hrs - [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 ### C-016: Notifications Display
- [ ] Notification bell in header with unread count - [x] Fixed notification bell unread count (was counting read records, now counts unread alerts)
- [ ] Alert banner on dashboard for IsAlert=true notifications - [x] Notification service: `getActiveNotifications()`, `getUnreadAlertCount()`, `markNotificationRead()`
- [ ] Notification list view - [x] Alert banner on dashboard uses correct unread count for current user
- [ ] Mark as read → create `quest_user_notification_alert_read` record - [x] `/(portal)/notifications/page.tsx` — notification list with unread alerts section
- **Deps:** F-005, F-009 | **Est:** 3 hrs - [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
--- ---

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</div>
</CardContent>
</Card>
);
}
export default function InvoicesPage() {
const [invoices, setInvoices] = useState<InvoiceEntry[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Invoices</h1>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="mb-4 h-12 w-12 text-muted-foreground/40" />
<p className="text-lg font-medium text-muted-foreground">
Invoice access is not available
</p>
<p className="text-sm text-muted-foreground">
Your company does not have invoice viewing enabled. Please contact
Vorteq support if you need access to invoices.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div>
<h1 className="mb-6 text-3xl font-bold">Invoices</h1>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load invoices: {error}
</CardContent>
</Card>
) : invoices === null ? (
<InvoiceSkeleton />
) : (
<InvoiceTable data={invoices} />
)}
</div>
);
}

View file

@ -7,7 +7,7 @@ import { PortalSidebar } from '@/components/layout/portal-sidebar';
import { PortalHeader } from '@/components/layout/portal-header'; import { PortalHeader } from '@/components/layout/portal-header';
import { Breadcrumb } from '@/components/layout/breadcrumb'; import { Breadcrumb } from '@/components/layout/breadcrumb';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { db } from '@/lib/db'; import { getUnreadAlertCount } from '@/services/notifications';
export default async function PortalLayout({ export default async function PortalLayout({
children, children,
@ -24,14 +24,8 @@ export default async function PortalLayout({
// Get active company // Get active company
const activeCompany = await getActiveCompany(); const activeCompany = await getActiveCompany();
// Get unread notifications count // Get unread alert notifications count (active alerts not yet read by this user)
const unreadNotifications = await db.quest_user_notification_alert_read.count( const unreadNotifications = await getUnreadAlertCount(session.user.id);
{
where: {
auth_user_id: session.user.id,
},
}
);
const isAdmin = const isAdmin =
session.userType === 'Admin' || session.userType === 'Super Admin'; session.userType === 'Admin' || session.userType === 'Super Admin';

View file

@ -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 (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="space-y-2">
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-full animate-pulse rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<NotificationItem[] | null>(
null
);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Notifications</h1>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load notifications: {error}
</CardContent>
</Card>
) : notifications === null ? (
<NotificationsSkeleton />
) : (
<NotificationList
notifications={notifications}
onMarkRead={handleMarkRead}
/>
)}
</div>
);
}

View file

@ -5,7 +5,7 @@ import {
getInventorySummary, getInventorySummary,
} from '@/services/dashboard'; } from '@/services/dashboard';
import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { db } from '@/lib/db'; import { getUnreadAlertCount } from '@/services/notifications';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -28,16 +28,7 @@ export async function GET() {
unprocessed_count: 0, unprocessed_count: 0,
total_weight: 0, total_weight: 0,
})), })),
db.quest_notification getUnreadAlertCount(session.user.id).catch(() => 0),
.count({
where: {
is_alert: true,
created_at: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
},
})
.catch(() => 0),
]); ]);
return NextResponse.json({ return NextResponse.json({

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Showing {sortedData.length} invoice{sortedData.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search invoice #, job #..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="invoice_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Invoice #
</SortableTableHead>
<SortableTableHead
sortKey="job_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Job #
</SortableTableHead>
<SortableTableHead
sortKey="page_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Page
</SortableTableHead>
<SortableTableHead
sortKey="created_at"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date
</SortableTableHead>
<SortableTableHead
sortKey="sent_at"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Sent Date
</SortableTableHead>
<th className="px-4 py-2 text-left text-sm font-medium">
PDF
</th>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
No invoices found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => (
<TableRow
key={row.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{row.invoice_number}
</TableCell>
<TableCell>{row.job_number || '-'}</TableCell>
<TableCell className="text-right">
{row.page_number ?? '-'}
</TableCell>
<TableCell>
{row.created_at
? formatDate(new Date(row.created_at))
: '-'}
</TableCell>
<TableCell>
{row.sent_at
? formatDate(new Date(row.sent_at))
: '-'}
</TableCell>
<TableCell>
{row.has_file ? (
<a
href={`/api/invoices/${row.id}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-teal-700 hover:text-teal-900"
title="Download PDF"
>
<FileText className="h-4 w-4" />
</a>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} invoices
</div>
</CardContent>
</Card>
);
}

View file

@ -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<void>;
};
export function NotificationList({ notifications, onMarkRead }: Props) {
const [markingRead, setMarkingRead] = useState<string | null>(null);
const [readIds, setReadIds] = useState<Set<string>>(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 (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Bell className="mb-4 h-12 w-12 text-muted-foreground/40" />
<p className="text-lg font-medium text-muted-foreground">
No notifications
</p>
<p className="text-sm text-muted-foreground">
You&apos;re all caught up!
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Unread Alerts Section */}
{unreadAlerts.length > 0 && (
<div>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold text-amber-700">
<AlertTriangle className="h-5 w-5" />
Unread Alerts ({unreadAlerts.length})
</h2>
<div className="space-y-3">
{unreadAlerts.map((notification) => (
<Card
key={notification.id}
className="border-amber-200 bg-amber-50"
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{notification.title}
</CardTitle>
<Badge
variant="destructive"
className="text-xs"
>
Alert
</Badge>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleMarkRead(notification.id)}
disabled={markingRead === notification.id}
className="shrink-0"
>
<CheckCircle className="mr-1 h-4 w-4" />
{markingRead === notification.id
? 'Marking...'
: 'Mark as Read'}
</Button>
</div>
<CardDescription>
{formatDate(new Date(notification.display_date))}
{notification.display_time
? ` at ${notification.display_time}`
: ''}
</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">
{notification.text}
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Other Notifications */}
{otherNotifications.length > 0 && (
<div>
{unreadAlerts.length > 0 && (
<h2 className="mb-3 text-lg font-semibold text-muted-foreground">
All Notifications
</h2>
)}
<div className="space-y-3">
{otherNotifications.map((notification) => {
const isRead = notification.is_read || readIds.has(notification.id);
return (
<Card
key={notification.id}
className={isRead ? 'opacity-75' : ''}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<CardTitle
className={`text-base ${isRead ? 'font-normal' : ''}`}
>
{notification.title}
</CardTitle>
{notification.is_alert && (
<Badge variant="secondary" className="text-xs">
Alert
</Badge>
)}
{isRead && (
<Badge variant="outline" className="text-xs">
Read
</Badge>
)}
</div>
{notification.is_alert && !isRead && (
<Button
variant="outline"
size="sm"
onClick={() => handleMarkRead(notification.id)}
disabled={markingRead === notification.id}
className="shrink-0"
>
<CheckCircle className="mr-1 h-4 w-4" />
{markingRead === notification.id
? 'Marking...'
: 'Mark as Read'}
</Button>
)}
</div>
<CardDescription>
{formatDate(new Date(notification.display_date))}
{notification.display_time
? ` at ${notification.display_time}`
: ''}
</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{notification.text}
</p>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
</div>
);
}

10
src/lib/storage.ts Normal file
View file

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

68
src/services/invoices.ts Normal file
View file

@ -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<InvoiceEntry[]> {
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;
}
}

View file

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

9
src/types/invoices.ts Normal file
View file

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

View file

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