feat: consent records page, tab persistence, and snippet copy fix (#9)
feat: consent records list endpoint and top-level admin page
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import { trackPageView } from './services/analytics';
|
import { trackPageView } from './services/analytics';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
import ConsentRecordsPage from './pages/ConsentRecordsPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import SiteDetailPage from './pages/SiteDetailPage';
|
import SiteDetailPage from './pages/SiteDetailPage';
|
||||||
@@ -57,6 +58,7 @@ function AppRoutes() {
|
|||||||
<Route path="/sites" element={<SitesPage />} />
|
<Route path="/sites" element={<SitesPage />} />
|
||||||
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
||||||
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
||||||
|
<Route path="/consent" element={<ConsentRecordsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
{extensionPages
|
{extensionPages
|
||||||
.filter((p) => p.protected !== false)
|
.filter((p) => p.protected !== false)
|
||||||
|
|||||||
12
apps/admin-ui/src/api/consent.ts
Normal file
12
apps/admin-ui/src/api/consent.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ConsentRecord, PaginatedResponse } from '../types/api';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export async function listConsentRecords(
|
||||||
|
siteId: string,
|
||||||
|
params?: { visitor_id?: string; page?: number; page_size?: number },
|
||||||
|
): Promise<PaginatedResponse<ConsentRecord>> {
|
||||||
|
const { data } = await apiClient.get<PaginatedResponse<ConsentRecord>>('/consent/', {
|
||||||
|
params: { site_id: siteId, ...params },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { getNavItems } from '../extensions/registry';
|
|||||||
|
|
||||||
const CORE_NAV_ITEMS = [
|
const CORE_NAV_ITEMS = [
|
||||||
{ path: '/sites', label: 'Sites', order: 10 },
|
{ path: '/sites', label: 'Sites', order: 10 },
|
||||||
|
{ path: '/consent', label: 'Consent Records', order: 15 },
|
||||||
{ path: '/settings', label: 'Settings', order: 90 },
|
{ path: '/settings', label: 'Settings', order: 90 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
235
apps/admin-ui/src/components/SiteConsentTab.tsx
Normal file
235
apps/admin-ui/src/components/SiteConsentTab.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { listConsentRecords } from '../api/consent';
|
||||||
|
import type { ConsentRecord } from '../types/api';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
import { LoadingState } from './ui/loading-state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionVariant(action: string): 'success' | 'error' | 'warning' | 'neutral' {
|
||||||
|
const map: Record<string, 'success' | 'error' | 'warning'> = {
|
||||||
|
accept_all: 'success',
|
||||||
|
reject_all: 'error',
|
||||||
|
custom: 'warning',
|
||||||
|
withdraw: 'error',
|
||||||
|
};
|
||||||
|
return map[action] ?? 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
accept_all: 'Accept all',
|
||||||
|
reject_all: 'Reject all',
|
||||||
|
custom: 'Custom',
|
||||||
|
withdraw: 'Withdrawn',
|
||||||
|
};
|
||||||
|
return map[action] ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordDetail({ record }: { record: ConsentRecord }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="bg-mist px-4 py-3">
|
||||||
|
<div className="grid gap-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Visitor ID</span>
|
||||||
|
<p className="mt-0.5 break-all font-mono">{record.visitor_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Page URL</span>
|
||||||
|
<p className="mt-0.5 break-all">{record.page_url ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Accepted</span>
|
||||||
|
<p className="mt-0.5">{record.categories_accepted.join(', ') || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Rejected</span>
|
||||||
|
<p className="mt-0.5">{record.categories_rejected?.join(', ') || '—'}</p>
|
||||||
|
</div>
|
||||||
|
{record.country_code && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">Location</span>
|
||||||
|
<p className="mt-0.5">{record.region_code ? `${record.country_code}-${record.region_code}` : record.country_code}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.tc_string && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">TC String</span>
|
||||||
|
<p className="mt-0.5 break-all font-mono text-[11px]">{record.tc_string}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.gpc_detected != null && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-secondary">GPC</span>
|
||||||
|
<p className="mt-0.5">
|
||||||
|
Detected: {record.gpc_detected ? 'Yes' : 'No'}
|
||||||
|
{record.gpc_honoured != null && ` · Honoured: ${record.gpc_honoured ? 'Yes' : 'No'}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteConsentTab({ siteId }: Props) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeSearch, setActiveSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['consent', siteId, activeSearch, page],
|
||||||
|
queryFn: () =>
|
||||||
|
listConsentRecords(siteId, {
|
||||||
|
visitor_id: activeSearch || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setActiveSearch(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Search */}
|
||||||
|
<Card className="mb-6 p-5">
|
||||||
|
<h3 className="font-heading mb-3 text-sm font-semibold text-foreground">
|
||||||
|
Search Consent Records
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="min-w-[280px] flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-text-tertiary focus:border-copper focus:outline-none"
|
||||||
|
placeholder="Search by visitor ID..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch}>Search</Button>
|
||||||
|
{activeSearch && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setActiveSearch('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeSearch && (
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
Showing results for visitor: <code className="rounded bg-mist px-1.5 py-0.5 font-mono">{activeSearch}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingState message="Loading consent records..." />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-text-secondary">
|
||||||
|
{activeSearch
|
||||||
|
? 'No consent records found for this visitor.'
|
||||||
|
: 'No consent records yet.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between text-xs text-text-secondary">
|
||||||
|
<span>{data.total} record{data.total !== 1 ? 's' : ''}</span>
|
||||||
|
<span>Page {page} of {totalPages}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table className="min-w-full divide-y divide-border text-sm">
|
||||||
|
<thead className="bg-background">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Visitor</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Action</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Categories</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary">Date</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-text-secondary" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{data.items.map((record) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={record.id}
|
||||||
|
className="cursor-pointer hover:bg-mist"
|
||||||
|
onClick={() => setExpandedId(expandedId === record.id ? null : record.id)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{record.visitor_id.length > 16
|
||||||
|
? record.visitor_id.slice(0, 8) + '…' + record.visitor_id.slice(-8)
|
||||||
|
: record.visitor_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={actionVariant(record.action)}>
|
||||||
|
{actionLabel(record.action)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{record.categories_accepted.join(', ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary">
|
||||||
|
{new Date(record.consented_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-tertiary">
|
||||||
|
{expandedId === record.id ? '▲' : '▼'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedId === record.id && (
|
||||||
|
<RecordDetail key={`${record.id}-detail`} record={record} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { MetricCard } from './ui/metric-card';
|
import { MetricCard } from './ui/metric-card';
|
||||||
import type { Site, SiteConfig } from '../types/api';
|
import type { Site, SiteConfig } from '../types/api';
|
||||||
@@ -8,7 +10,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SiteOverviewTab({ site, config }: Props) {
|
export default function SiteOverviewTab({ site, config }: Props) {
|
||||||
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}" async></script>`;
|
const scriptTag = `<script src="${window.location.origin}/consent-loader.js" data-site-id="${site.id}" data-api-base="${window.location.origin}"></script>`;
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -36,17 +39,38 @@ export default function SiteOverviewTab({ site, config }: Props) {
|
|||||||
<p className="mb-3 text-sm text-text-secondary">
|
<p className="mb-3 text-sm text-text-secondary">
|
||||||
Add this script tag to the {'<head>'} of your website, before any other scripts.
|
Add this script tag to the {'<head>'} of your website, before any other scripts.
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="flex items-stretch">
|
||||||
<pre className="overflow-x-auto rounded-lg bg-foreground p-4 text-sm text-status-success-fg">
|
<input
|
||||||
{scriptTag}
|
type="text"
|
||||||
</pre>
|
readOnly
|
||||||
|
value={scriptTag}
|
||||||
|
className="block w-full min-w-0 rounded-l-lg border border-r-0 border-border bg-mist px-3 py-2.5 font-mono text-xs text-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(scriptTag)}
|
type="button"
|
||||||
className="absolute right-3 top-3 rounded bg-foreground/80 px-2 py-1 text-xs text-card hover:bg-foreground/70"
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(scriptTag).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex shrink-0 items-center gap-2 rounded-r-lg border border-copper bg-copper px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-copper/90 focus:outline-none focus:ring-2 focus:ring-copper/50"
|
||||||
>
|
>
|
||||||
Copy
|
{copied ? (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-3 5h3m-6 0h.01M12 16h3m-6 0h.01M10 3v4h4V3h-4Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-text-secondary">
|
||||||
|
Must be the first {'<script>'} in {'<head>'} — no <code>async</code> or <code>defer</code>.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
|
|||||||
55
apps/admin-ui/src/pages/ConsentRecordsPage.tsx
Normal file
55
apps/admin-ui/src/pages/ConsentRecordsPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { listSites } from '../api/sites';
|
||||||
|
import SiteConsentTab from '../components/SiteConsentTab';
|
||||||
|
import { Select } from '../components/ui/select';
|
||||||
|
import type { Site } from '../types/api';
|
||||||
|
|
||||||
|
export default function ConsentRecordsPage() {
|
||||||
|
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
|
||||||
|
|
||||||
|
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
|
||||||
|
queryKey: ['sites'],
|
||||||
|
queryFn: listSites,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">
|
||||||
|
Consent Records
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">
|
||||||
|
View and search consent records across your sites.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 max-w-xs">
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Site
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedSiteId}
|
||||||
|
onChange={(e) => setSelectedSiteId(e.target.value)}
|
||||||
|
disabled={sitesLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a site…</option>
|
||||||
|
{sites?.map((site) => (
|
||||||
|
<option key={site.id} value={site.id}>
|
||||||
|
{site.display_name ?? site.domain}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSiteId ? (
|
||||||
|
<SiteConsentTab siteId={selectedSiteId} />
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-text-secondary">
|
||||||
|
Select a site to view its consent records.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
import { getSite, getSiteConfig, updateSiteConfig } from '../api/sites';
|
||||||
import SiteCategoriesTab from '../components/SiteCategoriesTab';
|
import SiteCategoriesTab from '../components/SiteCategoriesTab';
|
||||||
@@ -25,7 +25,15 @@ const CORE_TABS: { id: string; label: string; order: number }[] = [
|
|||||||
|
|
||||||
export default function SiteDetailPage() {
|
export default function SiteDetailPage() {
|
||||||
const { siteId } = useParams<{ siteId: string }>();
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Persist the active tab in the URL hash so a page refresh restores it.
|
||||||
|
const activeTab = location.hash.replace('#', '') || 'overview';
|
||||||
|
const setActiveTab = useCallback(
|
||||||
|
(tab: string) => navigate({ hash: tab }, { replace: true }),
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
|
const extensionTabs = useMemo(() => getSiteDetailTabs(), []);
|
||||||
const allTabs = useMemo(() => {
|
const allTabs = useMemo(() => {
|
||||||
|
|||||||
@@ -726,3 +726,28 @@ export interface ConsentReceiptResponse {
|
|||||||
banner_version_hash: string | null;
|
banner_version_hash: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConsentRecord {
|
||||||
|
id: string;
|
||||||
|
site_id: string;
|
||||||
|
visitor_id: string;
|
||||||
|
action: string;
|
||||||
|
categories_accepted: string[];
|
||||||
|
categories_rejected: string[] | null;
|
||||||
|
tc_string: string | null;
|
||||||
|
gcm_state: Record<string, string> | null;
|
||||||
|
gpp_string: string | null;
|
||||||
|
gpc_detected: boolean | null;
|
||||||
|
gpc_honoured: boolean | null;
|
||||||
|
page_url: string | null;
|
||||||
|
country_code: string | null;
|
||||||
|
region_code: string | null;
|
||||||
|
consented_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.db import get_db
|
from src.db import get_db
|
||||||
@@ -11,6 +11,7 @@ from src.models.site import Site
|
|||||||
from src.schemas.auth import CurrentUser
|
from src.schemas.auth import CurrentUser
|
||||||
from src.schemas.consent import (
|
from src.schemas.consent import (
|
||||||
ConsentRecordCreate,
|
ConsentRecordCreate,
|
||||||
|
ConsentRecordListResponse,
|
||||||
ConsentRecordResponse,
|
ConsentRecordResponse,
|
||||||
ConsentVerifyResponse,
|
ConsentVerifyResponse,
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,63 @@ async def _load_record_for_org(
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ConsentRecordListResponse)
|
||||||
|
async def list_consent_records(
|
||||||
|
site_id: uuid.UUID = Query(..., description="Filter by site"),
|
||||||
|
visitor_id: str | None = Query(None, description="Filter by visitor ID (exact match)"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: CurrentUser = Depends(require_role("owner", "admin", "editor", "viewer")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""List consent records for a site, with optional visitor_id filter.
|
||||||
|
|
||||||
|
Tenant-isolated — the site must belong to the caller's organisation.
|
||||||
|
Returns newest records first.
|
||||||
|
"""
|
||||||
|
# Verify site belongs to the caller's org.
|
||||||
|
site = (
|
||||||
|
await db.execute(
|
||||||
|
select(Site).where(
|
||||||
|
Site.id == site_id,
|
||||||
|
Site.organisation_id == current_user.organisation_id,
|
||||||
|
Site.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if site is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||||
|
|
||||||
|
base = select(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
||||||
|
count_base = (
|
||||||
|
select(func.count()).select_from(ConsentRecord).where(ConsentRecord.site_id == site_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if visitor_id:
|
||||||
|
base = base.where(ConsentRecord.visitor_id == visitor_id)
|
||||||
|
count_base = count_base.where(ConsentRecord.visitor_id == visitor_id)
|
||||||
|
|
||||||
|
total = await db.scalar(count_base) or 0
|
||||||
|
items = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
base.order_by(ConsentRecord.consented_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": list(items),
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
|
@router.get("/{consent_id}", response_model=ConsentRecordResponse)
|
||||||
async def get_consent(
|
async def get_consent(
|
||||||
consent_id: uuid.UUID,
|
consent_id: uuid.UUID,
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class ConsentRecordResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentRecordListResponse(BaseModel):
|
||||||
|
"""Paginated list of consent records."""
|
||||||
|
|
||||||
|
items: list[ConsentRecordResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
class ConsentVerifyResponse(BaseModel):
|
class ConsentVerifyResponse(BaseModel):
|
||||||
"""Audit proof that a consent record exists."""
|
"""Audit proof that a consent record exists."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user