diff --git a/apps/admin/app/dashboard/catalog/page.tsx b/apps/admin/app/dashboard/catalog/page.tsx index 8d6133bdd9..bb8d43642f 100644 --- a/apps/admin/app/dashboard/catalog/page.tsx +++ b/apps/admin/app/dashboard/catalog/page.tsx @@ -112,7 +112,7 @@ export default function CatalogPage() { const q = searchParams?.get('q') ?? undefined; const { - data: items = [], + data: result, isLoading, isError, } = useQuery({ @@ -120,6 +120,9 @@ export default function CatalogPage() { queryFn: () => getCatalogItems({ q }), }); + const items = result?.data ?? []; + const total = result?.total ?? 0; + return (
@@ -174,7 +177,8 @@ export default function CatalogPage() {

- {items.length.toLocaleString()} item{items.length !== 1 ? 's' : ''} + {items.length.toLocaleString()} of {total.toLocaleString()} item + {total !== 1 ? 's' : ''} {q ? ` matching "${q}"` : ''}

diff --git a/apps/admin/app/dashboard/packs/page.tsx b/apps/admin/app/dashboard/packs/page.tsx index a2b826a5cb..d498628174 100644 --- a/apps/admin/app/dashboard/packs/page.tsx +++ b/apps/admin/app/dashboard/packs/page.tsx @@ -16,7 +16,9 @@ import { SearchInput } from 'admin-app/components/search-input'; import { type AdminPack, deletePack, getPacks } from 'admin-app/lib/api'; import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; +import { cn } from 'admin-app/lib/utils'; import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; function TableSkeleton() { return ( @@ -41,22 +43,28 @@ function TableSkeleton() { function PackRow({ pack }: { pack: AdminPack }) { const queryClient = useQueryClient(); + const isDeleted = pack.deleted; const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deletePack(pack.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.packs() }); + queryClient.invalidateQueries({ queryKey: ['admin', 'packs'] }); }, }); return ( - +

{pack.name}

{pack.description && (

{pack.description}

)} + {isDeleted && ( +

+ Deleted {pack.deletedAt ? formatDate(new Date(pack.deletedAt)) : ''} +

+ )}
@@ -69,7 +77,10 @@ function PackRow({ pack }: { pack: AdminPack }) { {pack.isPublic ? 'Public' : 'Private'} @@ -80,13 +91,15 @@ function PackRow({ pack }: { pack: AdminPack }) { - { - await handleDelete(); - }} - /> + {!isDeleted && ( + { + await handleDelete(); + }} + /> + )}
); @@ -95,16 +108,20 @@ function PackRow({ pack }: { pack: AdminPack }) { export default function PacksPage() { const searchParams = useSearchParams(); const q = searchParams?.get('q') ?? undefined; + const [includeDeleted, setIncludeDeleted] = useState(false); const { - data: packs = [], + data: result, isLoading, isError, } = useQuery({ - queryKey: queryKeys.admin.packs(q), - queryFn: () => getPacks({ q }), + queryKey: [...queryKeys.admin.packs(q), { includeDeleted }], + queryFn: () => getPacks({ q, includeDeleted }), }); + const packs = result?.data ?? []; + const total = result?.total ?? 0; + return (
@@ -114,7 +131,18 @@ export default function PacksPage() {

- +
+ + +
{isError ? (

Failed to load packs. Check that the API is reachable. @@ -159,7 +187,8 @@ export default function PacksPage() {

- {packs.length.toLocaleString()} pack{packs.length !== 1 ? 's' : ''} + {packs.length.toLocaleString()} of {total.toLocaleString()} pack + {total !== 1 ? 's' : ''} {q ? ` matching "${q}"` : ''}

diff --git a/apps/admin/app/dashboard/page.tsx b/apps/admin/app/dashboard/page.tsx index d2543b8eae..7992c482ea 100644 --- a/apps/admin/app/dashboard/page.tsx +++ b/apps/admin/app/dashboard/page.tsx @@ -52,21 +52,25 @@ export default function DashboardPage() { queryFn: getStats, }); - const { data: users = [], isLoading: usersLoading } = useQuery({ + const { data: usersResult, isLoading: usersLoading } = useQuery({ queryKey: queryKeys.admin.users(5), queryFn: () => getUsers({ limit: 5 }), }); - const { data: packs = [], isLoading: packsLoading } = useQuery({ + const { data: packsResult, isLoading: packsLoading } = useQuery({ queryKey: queryKeys.admin.packs(5), queryFn: () => getPacks({ limit: 5 }), }); - const { data: catalog = [], isLoading: catalogLoading } = useQuery({ + const { data: catalogResult, isLoading: catalogLoading } = useQuery({ queryKey: queryKeys.admin.catalog(5), queryFn: () => getCatalogItems({ limit: 5 }), }); + const users = usersResult?.data ?? []; + const packs = packsResult?.data ?? []; + const catalog = catalogResult?.data ?? []; + const isLoading = statsLoading || usersLoading || packsLoading || catalogLoading; const isError = !isLoading && !stats; diff --git a/apps/admin/app/dashboard/trails/page.tsx b/apps/admin/app/dashboard/trails/page.tsx index c1f21a4bc2..f7c73ab163 100644 --- a/apps/admin/app/dashboard/trails/page.tsx +++ b/apps/admin/app/dashboard/trails/page.tsx @@ -1,20 +1,40 @@ 'use client'; +import { Badge } from '@packrat/web-ui/components/badge'; import { Button } from '@packrat/web-ui/components/button'; import { Input } from '@packrat/web-ui/components/input'; -import { useQuery } from '@tanstack/react-query'; -import { getTrailGeometry, type TrailGeometry } from 'admin-app/lib/api'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@packrat/web-ui/components/table'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { DeleteButton } from 'admin-app/components/delete-button'; +import { + deleteTrailCondition, + getTrailConditions, + getTrailGeometry, + searchTrails, + type TrailConditionReport, + type TrailGeometry, + type TrailSearchResult, +} from 'admin-app/lib/api'; +import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; +import { cn } from 'admin-app/lib/utils'; import dynamic from 'next/dynamic'; import { useState } from 'react'; -const LEADING_SLASH_RE = /^r?\//; - const TrailMap = dynamic(() => import('admin-app/components/trail-map').then((m) => m.TrailMap), { ssr: false, loading: () =>
, }); +// ─── Shared meta badge ──────────────────────────────────────────────────────── + function MetaBadge({ label, value }: { label: string; value: string | null | undefined }) { if (!value) return null; return ( @@ -25,51 +45,185 @@ function MetaBadge({ label, value }: { label: string; value: string | null | und ); } -export default function TrailViewerPage() { - const [input, setInput] = useState(''); - const [osmId, setOsmId] = useState(''); +// ─── Trail search section ───────────────────────────────────────────────────── - const { data, isLoading, isError, error } = useQuery({ - queryKey: queryKeys.osm.trail(osmId), - queryFn: () => getTrailGeometry(osmId), - enabled: osmId.length > 0, - retry: false, +const SPORT_OPTIONS = ['hiking', 'cycling', 'skiing', 'running', 'mtb', 'horse_riding']; + +function TrailSearchSection({ + onSelect, + selectedOsmId, +}: { + onSelect: (osmId: string) => void; + selectedOsmId: string; +}) { + const [inputQ, setInputQ] = useState(''); + const [inputSport, setInputSport] = useState(''); + const [activeQ, setActiveQ] = useState(''); + const [activeSport, setActiveSport] = useState(''); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.osm.search(activeQ, activeSport || undefined), + queryFn: () => searchTrails({ q: activeQ, sport: activeSport || undefined }), + enabled: activeQ.length > 0, }); - function handleSubmit(e: React.FormEvent) { + function handleSearch(e: React.FormEvent) { e.preventDefault(); - const trimmed = input.trim().replace(LEADING_SLASH_RE, ''); - if (trimmed) setOsmId(trimmed); + const q = inputQ.trim(); + if (q) { + setActiveQ(q); + setActiveSport(inputSport); + } } return ( -
-
-

Trail Viewer

-

- Enter an OSM relation ID to visualise its geometry and verify stitching. -

-
- -
+
+ setInput(e.target.value)} - className="font-mono" + placeholder="Search trail name (e.g. John Muir Trail)…" + value={inputQ} + onChange={(e) => setInputQ(e.target.value)} + className="flex-1 min-w-[240px]" /> - + {isError && ( +

+ Trail search failed — check that the OSM database is reachable. +

+ )} + + {isLoading && ( +

Searching trails…

+ )} + + {data && data.trails.length === 0 && ( +

+ No trails found matching “{activeQ}” + {activeSport ? ` (sport: ${activeSport})` : ''}. +

+ )} + + {data && data.trails.length > 0 && ( +
+ + + + Name + + OSM ID + + Sport + + Distance + + + Difficulty + + + + + + {data.trails.map((trail: TrailSearchResult) => ( + onSelect(trail.osmId)} + > + +

+ {trail.name ?? unnamed} +

+
+ + {trail.osmId} + + + {trail.sport ? ( + + {trail.sport} + + ) : ( + + )} + + + {trail.distance ?? '—'} + + + {trail.difficulty ?? '—'} + + + + +
+ ))} +
+
+ {data.hasMore && ( +

+ Showing first {data.trails.length} results — refine your search for more specific + matches. +

+ )} +
+ )} +
+ ); +} + +// ─── Trail viewer section ───────────────────────────────────────────────────── + +function TrailViewerSection({ osmId }: { osmId: string }) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: queryKeys.osm.trail(osmId), + queryFn: () => getTrailGeometry(osmId), + enabled: osmId.length > 0, + retry: false, + }); + + if (!osmId) return null; + + return ( +
+

Trail Viewer

+ {isError && (

{error instanceof Error ? error.message : 'Failed to load trail'}

)} - {isLoading &&

Loading trail…

} + {isLoading && ( +

Loading trail geometry…

+ )} {data && ( <> @@ -92,11 +246,11 @@ export default function TrailViewerPage() { )}
-
+
{data.geometry ? ( ) : ( -
+
No geometry available for this trail
)} @@ -106,3 +260,213 @@ export default function TrailViewerPage() {
); } + +// ─── Trail conditions section ───────────────────────────────────────────────── + +function ConditionRow({ report }: { report: TrailConditionReport }) { + const queryClient = useQueryClient(); + + const { mutateAsync: handleDelete } = useMutation({ + mutationFn: () => deleteTrailCondition(report.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['osm', 'conditions'] }); + }, + }); + + const conditionColor: Record = { + excellent: 'text-green-500', + good: 'text-blue-500', + fair: 'text-yellow-500', + poor: 'text-red-500', + }; + + return ( + + +
+

{report.trailName}

+ {report.trailRegion && ( +

{report.trailRegion}

+ )} +
+
+ + + {report.surface} + + + + + {report.overallCondition} + + + + {report.userEmail ?? '—'} + + + + {formatDate(new Date(report.createdAt))} + + + + {!report.deleted && ( + { + await handleDelete(); + }} + /> + )} + +
+ ); +} + +function TrailConditionsSection() { + const [search, setSearch] = useState(''); + const [activeSearch, setActiveSearch] = useState(''); + + const { data: result, isLoading } = useQuery({ + queryKey: queryKeys.osm.conditions(activeSearch || undefined), + queryFn: () => getTrailConditions({ q: activeSearch || undefined }), + }); + + const reports = result?.data ?? []; + const total = result?.total ?? 0; + + return ( +
+

Trail Condition Reports

+ +
{ + e.preventDefault(); + setActiveSearch(search.trim()); + }} + className="flex gap-2 max-w-sm" + > + setSearch(e.target.value)} + /> + +
+ + {isLoading ? ( +

Loading reports…

+ ) : ( + <> +
+ + + + + Trail + + + Surface + + + Condition + + + Reporter + + + Date + + + + + + {reports.length === 0 ? ( + + + No trail condition reports found + {activeSearch ? ` matching "${activeSearch}"` : ''}. + + + ) : ( + reports.map((r) => ) + )} + +
+
+

+ {reports.length.toLocaleString()} of {total.toLocaleString()} report + {total !== 1 ? 's' : ''} +

+ + )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +type Tab = 'search' | 'conditions'; + +export default function TrailsPage() { + const [activeTab, setActiveTab] = useState('search'); + const [selectedOsmId, setSelectedOsmId] = useState(''); + + function handleSelectTrail(osmId: string) { + setSelectedOsmId(osmId); + } + + const tabs: { id: Tab; label: string }[] = [ + { id: 'search', label: 'Trail Search' }, + { id: 'conditions', label: 'Condition Reports' }, + ]; + + return ( +
+
+

Trails

+

+ Search OSM routes, inspect geometry, and review condition reports. +

+
+ + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'search' && ( +
+ + {selectedOsmId && } +
+ )} + + {activeTab === 'conditions' && } +
+ ); +} diff --git a/apps/admin/app/dashboard/users/page.tsx b/apps/admin/app/dashboard/users/page.tsx index 7dfaa86cfa..4a62bb3f74 100644 --- a/apps/admin/app/dashboard/users/page.tsx +++ b/apps/admin/app/dashboard/users/page.tsx @@ -13,10 +13,12 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { DeleteButton } from 'admin-app/components/delete-button'; import { SearchInput } from 'admin-app/components/search-input'; -import { type AdminUser, deleteUser, getUsers } from 'admin-app/lib/api'; +import { type AdminUser, deleteUser, getUsers, restoreUser } from 'admin-app/lib/api'; import { formatDate } from 'admin-app/lib/date'; import { queryKeys } from 'admin-app/lib/queryKeys'; +import { cn } from 'admin-app/lib/utils'; import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; function TableSkeleton() { return ( @@ -31,6 +33,7 @@ function TableSkeleton() { +
))} @@ -40,16 +43,24 @@ function TableSkeleton() { function UserRow({ user }: { user: AdminUser }) { const queryClient = useQueryClient(); + const isDeleted = !!user.deletedAt; const { mutateAsync: handleDelete } = useMutation({ mutationFn: () => deleteUser(user.id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.admin.users() }); + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + }, + }); + + const { mutateAsync: handleRestore } = useMutation({ + mutationFn: () => restoreUser(user.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); }, }); return ( - +

@@ -60,6 +71,11 @@ function UserRow({ user }: { user: AdminUser }) { {(user.firstName || user.lastName) && (

{user.email}

)} + {isDeleted && ( +

+ Deleted {user.deletedAt ? formatDate(new Date(user.deletedAt)) : ''} +

+ )}
@@ -69,7 +85,10 @@ function UserRow({ user }: { user: AdminUser }) { {user.emailVerified ? 'Yes' : 'No'} @@ -80,13 +99,28 @@ function UserRow({ user }: { user: AdminUser }) { - { - await handleDelete(); - }} - /> + + {user.lastActiveAt ? formatDate(new Date(user.lastActiveAt)) : '—'} + + + + {isDeleted ? ( + + ) : ( + { + await handleDelete(); + }} + /> + )}
); @@ -95,16 +129,20 @@ function UserRow({ user }: { user: AdminUser }) { export default function UsersPage() { const searchParams = useSearchParams(); const q = searchParams?.get('q') ?? undefined; + const [includeDeleted, setIncludeDeleted] = useState(false); const { - data: users = [], + data: result, isLoading, isError, } = useQuery({ - queryKey: queryKeys.admin.users(q), - queryFn: () => getUsers({ q }), + queryKey: [...queryKeys.admin.users(q), { includeDeleted }], + queryFn: () => getUsers({ q, includeDeleted }), }); + const users = result?.data ?? []; + const total = result?.total ?? 0; + return (
@@ -114,7 +152,18 @@ export default function UsersPage() {

- +
+ + +
{isError ? (

Failed to load users. Check that the API is reachable. @@ -139,13 +188,16 @@ export default function UsersPage() { Joined + + Last Active + {users.length === 0 ? ( - + No users found{q ? ` matching "${q}"` : ''}. @@ -156,7 +208,8 @@ export default function UsersPage() {

- {users.length.toLocaleString()} user{users.length !== 1 ? 's' : ''} + {users.length.toLocaleString()} of {total.toLocaleString()} user + {total !== 1 ? 's' : ''} {q ? ` matching "${q}"` : ''}

diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 6e718eb8f6..3431215089 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -1,137 +1,168 @@ -// Browser-callable API client for the admin app. -// -// Auth: when behind CF Access, the Cf-Access-Jwt-Assertion header is added -// automatically by CF Access on every request to a protected service — no manual -// forwarding needed. For local dev, a short-lived Bearer token from Basic auth -// login is used instead. - +import { treaty } from '@elysiajs/eden'; +import type { App } from '@packrat/api'; +import type { + ActiveUsersSchema, + ActivityPointSchema, + AdminCatalogItemSchema, + AdminPackItemSchema, + AdminUserItemSchema, + BrandRowSchema, + BreakdownItemSchema, + CatalogOverviewSchema, + EmbeddingStatsSchema, + EtlJobSchema, + EtlResponseSchema, + GrowthPointSchema, + PriceBucketSchema, + TrailConditionReportSchema, + TrailGeometrySchema, + TrailSearchItemSchema, + TrailSearchResultSchema, +} from '@packrat/api/schemas/admin'; +import { isObject } from '@packrat/guards'; +import type { Static } from '@sinclair/typebox'; import { clearToken, getAuthHeader } from './auth'; import { adminEnv } from './env'; const API_BASE = adminEnv.NEXT_PUBLIC_API_URL; -async function adminFetch(path: string, init?: RequestInit): Promise { - const authHeaders = getAuthHeader(); - const res = await fetch(`${API_BASE}/api/admin${path}`, { - credentials: 'include', - ...init, - headers: { - 'Content-Type': 'application/json', - ...authHeaders, - ...init?.headers, - }, - }); +// Injects admin auth header and redirects to /login on 401. +const adminFetcher = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const authHeader = getAuthHeader(); + const headers = new Headers(init?.headers); + headers.set('Content-Type', 'application/json'); + for (const [k, v] of Object.entries(authHeader)) headers.set(k, v); + + const res = await fetch(input, { ...init, headers, credentials: 'include' }); if (res.status === 401) { clearToken(); if (typeof window !== 'undefined') window.location.replace('/login'); - throw new Error('Unauthorized'); } + return res; +}; - if (!res.ok) { - throw new Error(`Admin API error: ${res.status} ${res.statusText} — ${path}`); - } +// Pre-drilled into .api.admin so call sites write `adminClient.stats.get()`. +const adminClient = treaty(API_BASE, { + fetcher: adminFetcher as unknown as typeof fetch, + parseDate: false, +}).api.admin; + +function throwOnError(error: { value?: unknown } | null, fallback = 'Admin API error'): never { + const val = error?.value; + const msg = + isObject(val) && 'error' in val ? String((val as { error: unknown }).error) : fallback; + throw new Error(msg); +} - // T is caller-verified via the typed adminFetch call-sites above. - return res.json() as Promise; // safe-cast: fetch boundary — caller provides T +function unwrap(data: T | null | undefined, name: string): T { + if (data == null) throw new Error(`Admin API returned no data for ${name}`); + return data; } // ─── Stats ──────────────────────────────────────────────────────────────────── -export interface AdminStats { - users: number; - packs: number; - items: number; -} +export type AdminStats = { users: number; packs: number; items: number }; -export function getStats(): Promise { - return adminFetch('/stats'); +export async function getStats(): Promise { + const { data, error } = await adminClient.stats.get(); + if (error) throwOnError(error); + return unwrap(data, 'stats'); } // ─── Users ──────────────────────────────────────────────────────────────────── -export interface AdminUser { - id: number; - email: string; - firstName: string | null; - lastName: string | null; - role: string | null; - emailVerified: boolean | null; - createdAt: string | null; +export type AdminUser = Static; + +export interface PaginatedResponse { + data: T[]; + total: number; + limit: number; + offset: number; } -export function getUsers({ +export async function getUsers({ limit = 100, offset = 0, q, + includeDeleted = false, }: { limit?: number; offset?: number; q?: string; -} = {}): Promise { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (q) params.set('q', q); - return adminFetch(`/users-list?${params}`); + includeDeleted?: boolean; +} = {}): Promise> { + const { data, error } = await adminClient['users-list'].get({ + query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + }); + if (error) throwOnError(error); + return unwrap(data, 'users'); } -export function deleteUser(id: number): Promise<{ success: boolean }> { - return adminFetch(`/users/${id}`, { method: 'DELETE' }); +export async function deleteUser(id: number): Promise<{ success: boolean }> { + const { data, error } = await adminClient.users({ id: String(id) }).delete(); + if (error) throwOnError(error); + return unwrap(data, 'deleteUser'); } -// ─── Packs ──────────────────────────────────────────────────────────────────── +export async function hardDeleteUser( + id: number, + reason: string, +): Promise<{ success: boolean; purged: boolean }> { + const { data, error } = await adminClient.users({ id: String(id) }).hard.delete({ reason }); + if (error) throwOnError(error); + return unwrap(data, 'hardDeleteUser'); +} -export interface AdminPack { - id: string; - name: string; - description: string | null; - category: string; - isPublic: boolean | null; - createdAt: string | null; - userEmail: string | null; +export async function restoreUser(id: number): Promise<{ success: boolean }> { + const { data, error } = await adminClient.users({ id: String(id) }).restore.post(); + if (error) throwOnError(error); + return unwrap(data, 'restoreUser'); } -export function getPacks({ +// ─── Packs ──────────────────────────────────────────────────────────────────── + +export type AdminPack = Static; + +export async function getPacks({ limit = 100, offset = 0, q, + includeDeleted = false, }: { limit?: number; offset?: number; q?: string; -} = {}): Promise { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (q) params.set('q', q); - return adminFetch(`/packs-list?${params}`); + includeDeleted?: boolean; +} = {}): Promise> { + const { data, error } = await adminClient['packs-list'].get({ + query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + }); + if (error) throwOnError(error); + return unwrap(data, 'packs'); } -export function deletePack(id: string): Promise<{ success: boolean }> { - return adminFetch(`/packs/${id}`, { method: 'DELETE' }); +export async function deletePack(id: string): Promise<{ success: boolean }> { + const { data, error } = await adminClient.packs({ id }).delete(); + if (error) throwOnError(error); + return unwrap(data, 'deletePack'); } // ─── Catalog Items ──────────────────────────────────────────────────────────── -export interface AdminCatalogItem { - id: number; - name: string; - categories: string[] | null; - brand: string | null; - price: number | null; - weight: number | null; - weightUnit: string; - createdAt: string | null; -} +export type AdminCatalogItem = Static; export interface UpdateCatalogItemInput { name?: string; brand?: string | null; categories?: string[] | null; - weight?: number | null; + weight?: number; weightUnit?: string; price?: number | null; description?: string | null; } -export function getCatalogItems({ +export async function getCatalogItems({ limit = 100, offset = 0, q, @@ -139,136 +170,165 @@ export function getCatalogItems({ limit?: number; offset?: number; q?: string; -} = {}): Promise { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (q) params.set('q', q); - return adminFetch(`/catalog-list?${params}`); +} = {}): Promise> { + const { data, error } = await adminClient['catalog-list'].get({ + query: { limit, offset, q }, + }); + if (error) throwOnError(error); + return unwrap(data, 'catalog'); } -export function deleteCatalogItem(id: number): Promise<{ success: boolean }> { - return adminFetch(`/catalog/${id}`, { method: 'DELETE' }); +export async function deleteCatalogItem(id: number): Promise<{ success: boolean }> { + const { data, error } = await adminClient.catalog({ id: String(id) }).delete(); + if (error) throwOnError(error); + return unwrap(data, 'deleteCatalogItem'); } -export function updateCatalogItem( +export async function updateCatalogItem( id: number, - data: UpdateCatalogItemInput, + body: UpdateCatalogItemInput, ): Promise<{ id: number; name: string }> { - return adminFetch(`/catalog/${id}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); + const { data, error } = await adminClient.catalog({ id: String(id) }).patch(body); + if (error) throwOnError(error); + return unwrap(data, 'updateCatalogItem'); } // ─── Analytics — Platform ───────────────────────────────────────────────────── -export type GrowthPoint = { period: string; users: number; packs: number; catalogItems: number }; -export type ActivityPoint = { period: string; trips: number; trailReports: number; posts: number }; -export type BreakdownItem = { category: string; count: number }; - -export function getPlatformGrowth(period: string): Promise { - return adminFetch(`/analytics/platform/growth?period=${period}`); +export type GrowthPoint = Static; +export type ActivityPoint = Static; +export type BreakdownItem = Static; +export type ActiveUsers = Static; +export type AnalyticsPeriod = 'day' | 'week' | 'month'; + +export async function getPlatformGrowth( + period: AnalyticsPeriod, + range = 12, +): Promise { + const { data, error } = await adminClient.analytics.platform.growth.get({ + query: { period, range }, + }); + if (error) throwOnError(error); + return unwrap(data, 'platformGrowth'); } -export function getPlatformActivity(period: string): Promise { - return adminFetch(`/analytics/platform/activity?period=${period}`); +export async function getPlatformActivity( + period: AnalyticsPeriod, + range = 12, +): Promise { + const { data, error } = await adminClient.analytics.platform.activity.get({ + query: { period, range }, + }); + if (error) throwOnError(error); + return unwrap(data, 'platformActivity'); } -export function getPlatformBreakdown(): Promise { - return adminFetch('/analytics/platform/breakdown'); +export async function getPlatformBreakdown(): Promise { + const { data, error } = await adminClient.analytics.platform.breakdown.get(); + if (error) throwOnError(error); + return unwrap(data, 'platformBreakdown'); } // ─── Analytics — Catalog ───────────────────────────────────────────────────── -export type CatalogOverview = { - totalItems: number; - totalBrands: number; - avgPrice: number | null; - minPrice: number | null; - maxPrice: number | null; - embeddingCoverage: { total: number; withEmbedding: number; pct: number }; - availability: { status: string | null; count: number }[]; - addedLast30Days: number; -}; - -export type BrandRow = { - brand: string; - itemCount: number; - avgPrice: number | null; - minPrice: number | null; - maxPrice: number | null; - avgRating: number | null; -}; - -export type PriceBucket = { bucket: string; count: number }; - -export type EtlJob = { - id: string; - status: 'running' | 'completed' | 'failed'; - source: string; - filename: string; - scraperRevision: string; - startedAt: string; - completedAt: string | null; - totalProcessed: number | null; - totalValid: number | null; - totalInvalid: number | null; - successRate: number | null; -}; - -export type EtlResponse = { - jobs: EtlJob[]; - summary: { totalRuns: number; completed: number; failed: number; totalItemsIngested: number }; -}; - -export type EmbeddingStats = { - total: number; - withEmbedding: number; - pending: number; - coveragePct: number; -}; - -export function getCatalogOverview(): Promise { - return adminFetch('/analytics/catalog/overview'); +export type CatalogOverview = Static; +export type BrandRow = Static; +export type PriceBucket = Static; +export type EtlJob = Static; +export type EtlResponse = Static; +export type EmbeddingStats = Static; + +export async function getCatalogOverview(): Promise { + const { data, error } = await adminClient.analytics.catalog.overview.get(); + if (error) throwOnError(error); + return unwrap(data, 'catalogOverview'); } -export function getCatalogBrands(limit = 20): Promise { - return adminFetch(`/analytics/catalog/brands?limit=${limit}`); +export async function getCatalogBrands(limit = 20): Promise { + const { data, error } = await adminClient.analytics.catalog.brands.get({ + query: { limit }, + }); + if (error) throwOnError(error); + return unwrap(data, 'catalogBrands'); } -export function getCatalogPrices(): Promise { - return adminFetch('/analytics/catalog/prices'); +export async function getCatalogPrices(): Promise { + const { data, error } = await adminClient.analytics.catalog.prices.get(); + if (error) throwOnError(error); + return unwrap(data, 'catalogPrices'); } -export function getCatalogEtl(limit = 20): Promise { - return adminFetch(`/analytics/catalog/etl?limit=${limit}`); +export async function getCatalogEtl(limit = 20): Promise { + const { data, error } = await adminClient.analytics.catalog.etl.get({ + query: { limit }, + }); + if (error) throwOnError(error); + return unwrap(data, 'catalogEtl'); } -export function getCatalogEmbeddings(): Promise { - return adminFetch('/analytics/catalog/embeddings'); +export async function getCatalogEmbeddings(): Promise { + const { data, error } = await adminClient.analytics.catalog.embeddings.get(); + if (error) throwOnError(error); + return unwrap(data, 'catalogEmbeddings'); } -// ─── OSM Trail Viewer ───────────────────────────────────────────────────────── +// ─── Admin Trails ───────────────────────────────────────────────────────────── + +export type TrailSearchResult = Static; +export type TrailGeometry = Static; +export type TrailSearchPage = Static; +export type TrailConditionReport = Static; -async function trailsFetch(path: string): Promise { - const authHeaders = getAuthHeader(); - const res = await fetch(`${API_BASE}${path}`, { - headers: { 'Content-Type': 'application/json', ...authHeaders }, +export async function searchTrails({ + q, + sport, + limit = 50, + offset = 0, +}: { + q: string; + sport?: string; + limit?: number; + offset?: number; +}): Promise { + const { data, error } = await adminClient.trails.search.get({ + query: { q, sport, limit, offset }, }); - if (!res.ok) throw new Error(`Trails API error: ${res.status} ${res.statusText}`); - return res.json() as Promise; // safe-cast: fetch returns unknown JSON; caller is responsible for the shape via the generic T + if (error) throwOnError(error); + return unwrap(data, 'searchTrails'); +} + +export async function getTrailGeometry(osmId: string): Promise { + const { data, error } = await adminClient.trails({ osmId }).geometry.get(); + if (error) throwOnError(error); + return unwrap(data, 'trailGeometry'); } -export interface TrailGeometry { - osmId: string; - name: string | null; - sport: string | null; - network: string | null; - distance: string | null; - difficulty: string | null; - description: string | null; - geometry: object | null; +export async function getAdminTrail(osmId: string): Promise { + const { data, error } = await adminClient.trails({ osmId }).get(); + if (error) throwOnError(error); + return unwrap(data, 'adminTrail'); +} + +export async function getTrailConditions({ + q, + limit = 50, + offset = 0, + includeDeleted = false, +}: { + q?: string; + limit?: number; + offset?: number; + includeDeleted?: boolean; +} = {}): Promise> { + const { data, error } = await adminClient.trails.conditions.get({ + query: { q, limit, offset, includeDeleted: includeDeleted ? 'true' : undefined }, + }); + if (error) throwOnError(error); + return unwrap(data, 'trailConditions'); } -export function getTrailGeometry(osmId: string): Promise { - return trailsFetch(`/trails/${osmId}/geometry`); +export async function deleteTrailCondition(reportId: string): Promise<{ success: boolean }> { + const { data, error } = await adminClient.trails.conditions({ reportId }).delete(); + if (error) throwOnError(error); + return unwrap(data, 'deleteTrailCondition'); } diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts index 98edb35de5..6c4d6930d3 100644 --- a/apps/admin/lib/queryKeys.ts +++ b/apps/admin/lib/queryKeys.ts @@ -24,9 +24,11 @@ export const queryKeys = { breakdown: ['platform', 'breakdown'] as const, }, - /** OSM trail viewer queries. */ + /** OSM trail queries. */ osm: { trail: (osmId: string) => ['osm', 'trail', osmId] as const, + search: (q: string, sport?: string) => ['osm', 'search', q, sport] as const, + conditions: (q?: string) => ['osm', 'conditions', q] as const, }, /** Catalog analytics queries. */ diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 0deaeb4736..4213baab5f 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -12,7 +12,7 @@ import { getPackItemDetailOptions } from 'expo-app/features/packs/utils/getPackI import { getTripDetailOptions } from 'expo-app/features/trips/utils/getTripDetailOptions'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import type { TranslationFunction } from 'expo-app/lib/i18n/types'; -import { TestIds } from 'expo-app/lib/testIds'; +import { testIds } from 'expo-app/lib/testIds'; import 'expo-dev-client'; import { type Href, router, Stack, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; @@ -321,11 +321,7 @@ const getTripNewOptions = (t: TranslationFunction) => ({ headerLeft: () => { const router = useRouter(); return ( - router.back()} - className="px-2" - > + router.back()} className="px-2"> {t('common.cancel')} ); @@ -358,11 +354,7 @@ const getPackNewOptions = (t: TranslationFunction) => ({ headerLeft: () => { const router = useRouter(); return ( - router.back()} - className="px-2" - > + router.back()} className="px-2"> {t('common.cancel')} ); diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index fb2b12d50d..94a693bbc8 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -62,7 +62,7 @@ export function CatalogItemDetailScreen() { } return ( - + handleItemPress(item)} - testID={TestIds.CatalogItemCard} + testID={testIds.catalog.itemCard} /> )} ItemSeparatorComponent={ItemSeparatorComponent} diff --git a/apps/expo/features/trips/components/TripForm.tsx b/apps/expo/features/trips/components/TripForm.tsx index 390f4c4d0d..cd71757cd3 100644 --- a/apps/expo/features/trips/components/TripForm.tsx +++ b/apps/expo/features/trips/components/TripForm.tsx @@ -173,7 +173,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { {(field) => ( { {(field) => ( { return ( { Keyboard.dismiss(); setShowStartPicker(true); @@ -362,7 +362,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { return ( { Keyboard.dismiss(); setShowEndPicker(true); diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx index 758cdef8f7..8bc6a0f39b 100644 --- a/apps/expo/features/trips/screens/TripDetailScreen.tsx +++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx @@ -5,7 +5,7 @@ import { featureFlags } from 'expo-app/config'; import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { TestIds } from 'expo-app/lib/testIds'; +import { testIds } from 'expo-app/lib/testIds'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; import { Modal, ScrollView, Share, View } from 'react-native'; @@ -88,7 +88,7 @@ export function TripDetailScreen() { {/* Header */} {trip.name} diff --git a/apps/expo/features/trips/screens/TripListScreen.tsx b/apps/expo/features/trips/screens/TripListScreen.tsx index 78f439576e..ea2efee01d 100644 --- a/apps/expo/features/trips/screens/TripListScreen.tsx +++ b/apps/expo/features/trips/screens/TripListScreen.tsx @@ -129,7 +129,7 @@ export function TripsListScreen() { {filteredTrips.map((trip: Trip) => ( - + ))} @@ -174,7 +174,7 @@ export function TripsListScreen() { contentInsetAdjustmentBehavior="automatic" renderItem={({ item: trip }) => ( - + )} ListEmptyComponent={renderEmptyState()} diff --git a/apps/expo/lib/testIds.ts b/apps/expo/lib/testIds.ts index 6a42a7ed5a..fe5448600c 100644 --- a/apps/expo/lib/testIds.ts +++ b/apps/expo/lib/testIds.ts @@ -7,10 +7,6 @@ * - Dynamic IDs: factory functions (`testIds.packs.listItem(id)`) so callers * never hand-interpolate strings. * - * `TestIds` is a backward-compat alias that maps the old PascalCase enum keys - * to the same underlying strings. Maestro flows reference these values via - * `id:` selectors — keep the string values stable. - * * Usage in components: * import { testIds } from 'expo-app/lib/testIds'; * @@ -32,8 +28,9 @@ export const testIds = Object.freeze({ // ── Packs ───────────────────────────────────────────────────────────────── packs: Object.freeze({ createBtn: 'create-pack-button', // keep Maestro value - nameInput: 'pack-name-input', // keep Maestro value (Maestro: id: "pack-name-input") - descriptionInput: 'pack-description-input', // keep Maestro value (Maestro: id: "pack-description-input") + nameInput: 'pack-name-input', // keep Maestro value + descriptionInput: 'pack-description-input', // keep Maestro value + cancelBtn: 'cancel-pack-form-button', // keep Maestro value submitBtn: 'submit-pack-button', // keep Maestro value deleteBtn: 'packs:delete', editBtn: 'packs:edit', @@ -67,8 +64,14 @@ export const testIds = Object.freeze({ // ── Trips ───────────────────────────────────────────────────────────────── trips: Object.freeze({ createBtn: 'create-trip-button', // keep Maestro value - nameInput: 'trips:name-input', - descriptionInput: 'trips:description-input', + nameInput: 'trip-name-input', // keep Maestro value + descriptionInput: 'trip-description-input', // keep Maestro value + startDateRow: 'start-date-row', // keep Maestro value + endDateRow: 'end-date-row', // keep Maestro value + listCard: 'trip-list-item', // keep Maestro value + searchCard: 'trips:search-result', + detailName: 'trip-detail-name', // keep Maestro value + cancelBtn: 'cancel-trip-form-button', // keep Maestro value submitBtn: 'submit-trip-button', // keep Maestro value deleteBtn: 'trips:delete', editBtn: 'trips:edit', @@ -81,6 +84,8 @@ export const testIds = Object.freeze({ searchInput: 'catalog:search-input', addToPackBtn: 'add-to-pack-button', // keep Maestro value viewRetailerBtn: 'view-retailer-button', // keep Maestro value + itemCard: 'catalog-item-card', // keep Maestro value + detailContent: 'catalog-detail-content', // keep Maestro value item: (id: string | number) => `catalog:item-${id}`, }), @@ -108,20 +113,3 @@ export const testIds = Object.freeze({ location: (id: string | number) => `weather:location-${id}`, }), }); - -/** @deprecated Use the namespaced `testIds` object instead. */ -export const TestIds = { - PackNameInput: testIds.packs.nameInput, - PackDescriptionInput: testIds.packs.descriptionInput, - TripNameInput: 'trip-name-input', - TripDescriptionInput: 'trip-description-input', - StartDateRow: 'start-date-row', - EndDateRow: 'end-date-row', - TripSearchResult: 'trip-search-result', - TripListItem: 'trip-list-item', - TripDetailName: 'trip-detail-name', - CatalogItemCard: 'catalog-item-card', - CatalogDetailContent: 'catalog-detail-content', - CancelPackFormButton: 'cancel-pack-form-button', - CancelTripFormButton: 'cancel-trip-form-button', -} as const; diff --git a/packages/api/drizzle/0038_broad_winter_soldier.sql b/packages/api/drizzle/0038_broad_winter_soldier.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/api/drizzle/0039_worried_wrecking_crew.sql b/packages/api/drizzle/0039_worried_wrecking_crew.sql new file mode 100644 index 0000000000..ed45ba32fc --- /dev/null +++ b/packages/api/drizzle/0039_worried_wrecking_crew.sql @@ -0,0 +1,10 @@ +ALTER TABLE "pack_items" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "pack_template_items" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "pack_templates" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "packs" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "trail_condition_reports" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "trips" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "last_active_at" timestamp;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "post_comments" ADD COLUMN "deleted_at" timestamp; \ No newline at end of file diff --git a/packages/api/drizzle/meta/0037_snapshot.json b/packages/api/drizzle/meta/0037_snapshot.json index 36e6adff04..7dfc17736d 100644 --- a/packages/api/drizzle/meta/0037_snapshot.json +++ b/packages/api/drizzle/meta/0037_snapshot.json @@ -1,5 +1,5 @@ { - "id": "osm_trails_poc", + "id": "a7c5105c-b819-43ec-a26c-5f5a84dc86ba", "prevId": "fa3d18d1-67a7-488a-aba5-5b18295e80f2", "version": "7", "dialect": "postgresql", @@ -1676,8 +1676,7 @@ "name": "trail_osm_id", "type": "bigint", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false } }, "indexes": {}, @@ -1789,6 +1788,286 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_likes": { + "name": "post_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_likes_post_id_posts_id_fk": { + "name": "post_likes_post_id_posts_id_fk", + "tableFrom": "post_likes", + "tableTo": "posts", + "schemaTo": "public", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_likes_user_id_users_id_fk": { + "name": "post_likes_user_id_users_id_fk", + "tableFrom": "post_likes", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_post_id_user_id_unique": { + "name": "post_likes_post_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comments": { + "name": "post_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_comments_post_id_posts_id_fk": { + "name": "post_comments_post_id_posts_id_fk", + "tableFrom": "post_comments", + "tableTo": "posts", + "schemaTo": "public", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_user_id_users_id_fk": { + "name": "post_comments_user_id_users_id_fk", + "tableFrom": "post_comments", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_parent_comment_id_post_comments_id_fk": { + "name": "post_comments_parent_comment_id_post_comments_id_fk", + "tableFrom": "post_comments", + "tableTo": "post_comments", + "schemaTo": "public", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_likes": { + "name": "comment_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_likes_comment_id_post_comments_id_fk": { + "name": "comment_likes_comment_id_post_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "post_comments", + "schemaTo": "public", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_comment_id_user_id_unique": { + "name": "comment_likes_comment_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/packages/api/drizzle/meta/0038_snapshot.json b/packages/api/drizzle/meta/0038_snapshot.json new file mode 100644 index 0000000000..f624f918d7 --- /dev/null +++ b/packages/api/drizzle/meta/0038_snapshot.json @@ -0,0 +1,2084 @@ +{ + "id": "dce94129-f491-41b1-9d2c-e22dcf72101e", + "prevId": "a7c5105c-b819-43ec-a26c-5f5a84dc86ba", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_providers": { + "name": "auth_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_providers_user_id_users_id_fk": { + "name": "auth_providers_user_id_users_id_fk", + "tableFrom": "auth_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_item_etl_jobs": { + "name": "catalog_item_etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etl_job_id": { + "name": "etl_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": { + "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": { + "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "etl_jobs", + "columnsFrom": ["etl_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_items": { + "name": "catalog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating_value": { + "name": "rating_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "availability", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "seller": { + "name": "seller", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material": { + "name": "material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "techs": { + "name": "techs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "qas": { + "name": "qas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_idx": { + "name": "embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalog_items_sku_unique": { + "name": "catalog_items_sku_unique", + "nullsNotDistinct": false, + "columns": ["sku"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.etl_jobs": { + "name": "etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "etl_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_processed": { + "name": "total_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_valid": { + "name": "total_valid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_invalid": { + "name": "total_invalid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scraper_revision": { + "name": "scraper_revision", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "etl_jobs_scraper_revision_idx": { + "name": "etl_jobs_scraper_revision_idx", + "columns": [ + { + "expression": "scraper_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invalid_item_logs": { + "name": "invalid_item_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "row_index": { + "name": "row_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invalid_item_logs_job_id_etl_jobs_id_fk": { + "name": "invalid_item_logs_job_id_etl_jobs_id_fk", + "tableFrom": "invalid_item_logs", + "tableTo": "etl_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.one_time_passwords": { + "name": "one_time_passwords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "one_time_passwords_user_id_users_id_fk": { + "name": "one_time_passwords_user_id_users_id_fk", + "tableFrom": "one_time_passwords", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_items": { + "name": "pack_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_item_id": { + "name": "template_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pack_items_embedding_idx": { + "name": "pack_items_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "pack_items_pack_id_packs_id_fk": { + "name": "pack_items_pack_id_packs_id_fk", + "tableFrom": "pack_items", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_user_id_users_id_fk": { + "name": "pack_items_user_id_users_id_fk", + "tableFrom": "pack_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_template_item_id_pack_template_items_id_fk": { + "name": "pack_items_template_item_id_pack_template_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "pack_template_items", + "columnsFrom": ["template_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_template_items": { + "name": "pack_template_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_template_id": { + "name": "pack_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_template_items_pack_template_id_pack_templates_id_fk": { + "name": "pack_template_items_pack_template_id_pack_templates_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "pack_templates", + "columnsFrom": ["pack_template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_template_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_template_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_template_items_user_id_users_id_fk": { + "name": "pack_template_items_user_id_users_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_templates": { + "name": "pack_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_app_template": { + "name": "is_app_template", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_templates_user_id_users_id_fk": { + "name": "pack_templates_user_id_users_id_fk", + "tableFrom": "pack_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weight_history": { + "name": "weight_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "weight_history_user_id_users_id_fk": { + "name": "weight_history_user_id_users_id_fk", + "tableFrom": "weight_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weight_history_pack_id_packs_id_fk": { + "name": "weight_history_pack_id_packs_id_fk", + "tableFrom": "weight_history", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packs": { + "name": "packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "packs_user_id_users_id_fk": { + "name": "packs_user_id_users_id_fk", + "tableFrom": "packs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "packs_template_id_pack_templates_id_fk": { + "name": "packs_template_id_pack_templates_id_fk", + "tableFrom": "packs", + "tableTo": "pack_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token": { + "name": "replaced_by_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_content": { + "name": "reported_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_response": { + "name": "ai_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_comment": { + "name": "user_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed": { + "name": "reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reported_content_user_id_users_id_fk": { + "name": "reported_content_user_id_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reported_content_reviewed_by_users_id_fk": { + "name": "reported_content_reviewed_by_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["reviewed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trail_condition_reports": { + "name": "trail_condition_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trail_name": { + "name": "trail_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trail_region": { + "name": "trail_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overall_condition": { + "name": "overall_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hazards": { + "name": "hazards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "water_crossings": { + "name": "water_crossings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "water_crossing_difficulty": { + "name": "water_crossing_difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trail_condition_reports_user_id_idx": { + "name": "trail_condition_reports_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_active_created_idx": { + "name": "trail_condition_reports_active_created_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trail_name_idx": { + "name": "trail_condition_reports_trail_name_idx", + "columns": [ + { + "expression": "trail_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trip_id_idx": { + "name": "trail_condition_reports_trip_id_idx", + "columns": [ + { + "expression": "trip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trail_condition_reports\".\"trip_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trail_condition_reports_user_id_users_id_fk": { + "name": "trail_condition_reports_user_id_users_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trail_condition_reports_trip_id_trips_id_fk": { + "name": "trail_condition_reports_trip_id_trips_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "trips", + "columnsFrom": ["trip_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trail_osm_id": { + "name": "trail_osm_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "trips_user_id_users_id_fk": { + "name": "trips_user_id_users_id_fk", + "tableFrom": "trips", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trips_pack_id_packs_id_fk": { + "name": "trips_pack_id_packs_id_fk", + "tableFrom": "trips", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_likes": { + "name": "post_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_likes_post_id_posts_id_fk": { + "name": "post_likes_post_id_posts_id_fk", + "tableFrom": "post_likes", + "tableTo": "posts", + "schemaTo": "public", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_likes_user_id_users_id_fk": { + "name": "post_likes_user_id_users_id_fk", + "tableFrom": "post_likes", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_post_id_user_id_unique": { + "name": "post_likes_post_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comments": { + "name": "post_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_comments_post_id_posts_id_fk": { + "name": "post_comments_post_id_posts_id_fk", + "tableFrom": "post_comments", + "tableTo": "posts", + "schemaTo": "public", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_user_id_users_id_fk": { + "name": "post_comments_user_id_users_id_fk", + "tableFrom": "post_comments", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_parent_comment_id_post_comments_id_fk": { + "name": "post_comments_parent_comment_id_post_comments_id_fk", + "tableFrom": "post_comments", + "tableTo": "post_comments", + "schemaTo": "public", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_likes": { + "name": "comment_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_likes_comment_id_post_comments_id_fk": { + "name": "comment_likes_comment_id_post_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "post_comments", + "schemaTo": "public", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_comment_id_user_id_unique": { + "name": "comment_likes_comment_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/drizzle/meta/0039_snapshot.json b/packages/api/drizzle/meta/0039_snapshot.json new file mode 100644 index 0000000000..4982183617 --- /dev/null +++ b/packages/api/drizzle/meta/0039_snapshot.json @@ -0,0 +1,2136 @@ +{ + "id": "acca2b7a-650e-4e23-83f3-47b309d37e6b", + "prevId": "dce94129-f491-41b1-9d2c-e22dcf72101e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_providers": { + "name": "auth_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_providers_user_id_users_id_fk": { + "name": "auth_providers_user_id_users_id_fk", + "tableFrom": "auth_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_item_etl_jobs": { + "name": "catalog_item_etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etl_job_id": { + "name": "etl_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": { + "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": { + "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "etl_jobs", + "columnsFrom": ["etl_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_items": { + "name": "catalog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating_value": { + "name": "rating_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "availability", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "seller": { + "name": "seller", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material": { + "name": "material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "techs": { + "name": "techs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "qas": { + "name": "qas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_idx": { + "name": "embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalog_items_sku_unique": { + "name": "catalog_items_sku_unique", + "nullsNotDistinct": false, + "columns": ["sku"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_likes": { + "name": "comment_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_likes_comment_id_post_comments_id_fk": { + "name": "comment_likes_comment_id_post_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "post_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_comment_id_user_id_unique": { + "name": "comment_likes_comment_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.etl_jobs": { + "name": "etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "etl_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_processed": { + "name": "total_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_valid": { + "name": "total_valid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_invalid": { + "name": "total_invalid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scraper_revision": { + "name": "scraper_revision", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "etl_jobs_scraper_revision_idx": { + "name": "etl_jobs_scraper_revision_idx", + "columns": [ + { + "expression": "scraper_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invalid_item_logs": { + "name": "invalid_item_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "row_index": { + "name": "row_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invalid_item_logs_job_id_etl_jobs_id_fk": { + "name": "invalid_item_logs_job_id_etl_jobs_id_fk", + "tableFrom": "invalid_item_logs", + "tableTo": "etl_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.one_time_passwords": { + "name": "one_time_passwords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "one_time_passwords_user_id_users_id_fk": { + "name": "one_time_passwords_user_id_users_id_fk", + "tableFrom": "one_time_passwords", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_items": { + "name": "pack_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_item_id": { + "name": "template_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pack_items_embedding_idx": { + "name": "pack_items_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "pack_items_pack_id_packs_id_fk": { + "name": "pack_items_pack_id_packs_id_fk", + "tableFrom": "pack_items", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_user_id_users_id_fk": { + "name": "pack_items_user_id_users_id_fk", + "tableFrom": "pack_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_template_item_id_pack_template_items_id_fk": { + "name": "pack_items_template_item_id_pack_template_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "pack_template_items", + "columnsFrom": ["template_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_template_items": { + "name": "pack_template_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_template_id": { + "name": "pack_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_template_items_pack_template_id_pack_templates_id_fk": { + "name": "pack_template_items_pack_template_id_pack_templates_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "pack_templates", + "columnsFrom": ["pack_template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_template_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_template_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_template_items_user_id_users_id_fk": { + "name": "pack_template_items_user_id_users_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_templates": { + "name": "pack_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_app_template": { + "name": "is_app_template", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_templates_user_id_users_id_fk": { + "name": "pack_templates_user_id_users_id_fk", + "tableFrom": "pack_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weight_history": { + "name": "weight_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "weight_history_user_id_users_id_fk": { + "name": "weight_history_user_id_users_id_fk", + "tableFrom": "weight_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weight_history_pack_id_packs_id_fk": { + "name": "weight_history_pack_id_packs_id_fk", + "tableFrom": "weight_history", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packs": { + "name": "packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "packs_user_id_users_id_fk": { + "name": "packs_user_id_users_id_fk", + "tableFrom": "packs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "packs_template_id_pack_templates_id_fk": { + "name": "packs_template_id_pack_templates_id_fk", + "tableFrom": "packs", + "tableTo": "pack_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comments": { + "name": "post_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "post_comments_post_id_posts_id_fk": { + "name": "post_comments_post_id_posts_id_fk", + "tableFrom": "post_comments", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_user_id_users_id_fk": { + "name": "post_comments_user_id_users_id_fk", + "tableFrom": "post_comments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_parent_comment_id_post_comments_id_fk": { + "name": "post_comments_parent_comment_id_post_comments_id_fk", + "tableFrom": "post_comments", + "tableTo": "post_comments", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_likes": { + "name": "post_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_likes_post_id_posts_id_fk": { + "name": "post_likes_post_id_posts_id_fk", + "tableFrom": "post_likes", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_likes_user_id_users_id_fk": { + "name": "post_likes_user_id_users_id_fk", + "tableFrom": "post_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_post_id_user_id_unique": { + "name": "post_likes_post_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token": { + "name": "replaced_by_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_content": { + "name": "reported_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_response": { + "name": "ai_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_comment": { + "name": "user_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed": { + "name": "reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reported_content_user_id_users_id_fk": { + "name": "reported_content_user_id_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reported_content_reviewed_by_users_id_fk": { + "name": "reported_content_reviewed_by_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["reviewed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trail_condition_reports": { + "name": "trail_condition_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trail_name": { + "name": "trail_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trail_region": { + "name": "trail_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overall_condition": { + "name": "overall_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hazards": { + "name": "hazards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "water_crossings": { + "name": "water_crossings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "water_crossing_difficulty": { + "name": "water_crossing_difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trail_condition_reports_user_id_idx": { + "name": "trail_condition_reports_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_active_created_idx": { + "name": "trail_condition_reports_active_created_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trail_name_idx": { + "name": "trail_condition_reports_trail_name_idx", + "columns": [ + { + "expression": "trail_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trip_id_idx": { + "name": "trail_condition_reports_trip_id_idx", + "columns": [ + { + "expression": "trip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trail_condition_reports\".\"trip_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trail_condition_reports_user_id_users_id_fk": { + "name": "trail_condition_reports_user_id_users_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trail_condition_reports_trip_id_trips_id_fk": { + "name": "trail_condition_reports_trip_id_trips_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "trips", + "columnsFrom": ["trip_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trail_osm_id": { + "name": "trail_osm_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "trips_user_id_users_id_fk": { + "name": "trips_user_id_users_id_fk", + "tableFrom": "trips", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trips_pack_id_packs_id_fk": { + "name": "trips_pack_id_packs_id_fk", + "tableFrom": "trips", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index b1510673dd..0107445106 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -274,6 +274,20 @@ "when": 1777170392780, "tag": "0037_trips_trail_osm_id", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1777637285430, + "tag": "0038_broad_winter_soldier", + "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1777637471970, + "tag": "0039_worried_wrecking_crew", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index e6b45ff41e..ab2578a1f3 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -33,6 +33,8 @@ export const users = pgTable('users', { role: text('role').default('USER'), // 'USER', 'ADMIN' createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), + lastActiveAt: timestamp('last_active_at'), + deletedAt: timestamp('deleted_at'), }); // Authentication providers table @@ -85,6 +87,7 @@ export const packs = pgTable('packs', { image: text('image'), tags: jsonb('tags').$type(), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), isAIGenerated: boolean('is_ai_generated').notNull().default(false), localCreatedAt: timestamp('local_created_at').notNull(), localUpdatedAt: timestamp('local_updated_at').notNull(), @@ -208,6 +211,7 @@ export const packItems = pgTable( .references(() => users.id) .notNull(), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), isAIGenerated: boolean('is_ai_generated').notNull().default(false), templateItemId: text('template_item_id').references(() => packTemplateItems.id), embedding: vector('embedding', { dimensions: 1536 }), @@ -248,6 +252,7 @@ export const packTemplates = pgTable('pack_templates', { tags: jsonb('tags').$type(), isAppTemplate: boolean('is_app_template').notNull().default(false), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), contentSource: text('content_source'), contentId: text('content_id'), @@ -279,6 +284,7 @@ export const packTemplateItems = pgTable('pack_template_items', { .references(() => users.id) .notNull(), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), @@ -303,6 +309,7 @@ export const trailConditionReports = pgTable( .notNull(), tripId: text('trip_id').references(() => trips.id, { onDelete: 'set null' }), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), localCreatedAt: timestamp('local_created_at').notNull(), localUpdatedAt: timestamp('local_updated_at').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), @@ -339,6 +346,7 @@ export const trips = pgTable('trips', { localCreatedAt: timestamp('local_created_at').notNull(), localUpdatedAt: timestamp('local_updated_at').notNull(), deleted: boolean('deleted').notNull().default(false), + deletedAt: timestamp('deleted_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); @@ -581,6 +589,7 @@ export const posts = pgTable('posts', { images: jsonb('images').$type().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }); // Post likes table @@ -616,6 +625,7 @@ export const postComments = pgTable('post_comments', { }), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), + deletedAt: timestamp('deleted_at'), }); // Comment likes table diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index e6e7b477b5..31721389e0 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -68,7 +68,7 @@ async function seedE2EUser() { if (existingUser) { await db .update(schema.users) - .set({ passwordHash, emailVerified: true, updatedAt: new Date() }) + .set({ passwordHash, emailVerified: true, deletedAt: null, updatedAt: new Date() }) .where(eq(schema.users.id, existingUser.id)); console.log(`E2E user refreshed: ${normalizedEmail} (id=${existingUser.id})`); } else { diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index a9ddf0dad7..0211f0c96b 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -1,4 +1,7 @@ +import { createDb } from '@packrat/api/db'; +import { users } from '@packrat/api/db/schema'; import { isValidApiKey, verifyJWT } from '@packrat/api/utils/auth'; +import { and, eq, isNull, lt, or } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; export type AuthUser = { @@ -25,10 +28,35 @@ export const authPlugin = new Elysia({ name: 'packrat-auth' }).macro({ const payload = await verifyJWT({ token }); if (!payload) return status(401, { error: 'Invalid token' }); + const uid = Number(payload.userId); + if (!Number.isFinite(uid) || uid <= 0) return status(401, { error: 'Unauthorized' }); + + const db = createDb(); + + // Reject soft-deleted accounts even when their JWT is still valid. + const dbUser = await db.query.users.findFirst({ + columns: { id: true, deletedAt: true }, + where: eq(users.id, uid), + }); + if (!dbUser || dbUser.deletedAt) return status(401, { error: 'Unauthorized' }); + + // Fire-and-forget: update last_active_at at most once per 5 min per user + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + db.update(users) + .set({ lastActiveAt: new Date() }) + .where( + and( + eq(users.id, uid), + isNull(users.deletedAt), + or(isNull(users.lastActiveAt), lt(users.lastActiveAt, fiveMinutesAgo)), + ), + ) + .catch(() => {}); + const { userId: _uid, role: _role, ...rest } = payload; return { user: { - userId: Number(payload.userId), + userId: uid, role: (payload.role as 'USER' | 'ADMIN') ?? 'USER', ...rest, } as AuthUser, // safe-cast: JWT payload validated by auth middleware — userId and role fields are confirmed present diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts index 40baab1c60..910b817e6e 100644 --- a/packages/api/src/routes/admin/analytics/catalog.ts +++ b/packages/api/src/routes/admin/analytics/catalog.ts @@ -1,7 +1,15 @@ import { createDb } from '@packrat/api/db'; import { catalogItems, etlJobs } from '@packrat/api/db/schema'; +import { + AdminErrorResponses, + BrandRowSchema, + CatalogOverviewSchema, + EmbeddingStatsSchema, + EtlResponseSchema, + PriceBucketSchema, +} from '@packrat/api/schemas/admin'; import { and, avg, count, desc, gt, isNotNull, max, min, sql } from 'drizzle-orm'; -import { Elysia, status } from 'elysia'; +import { Elysia, status, t } from 'elysia'; import { z } from 'zod'; export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) @@ -75,7 +83,10 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }); } }, - { detail: { tags: ['Admin'], summary: 'Catalog data lake overview' } }, + { + response: { 200: CatalogOverviewSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Catalog data lake overview' }, + }, ) .get( @@ -117,6 +128,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) query: z.object({ limit: z.coerce.number().int().min(1).max(100).optional().default(25), }), + response: { 200: t.Array(BrandRowSchema), ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Top gear brands' }, }, ) @@ -152,7 +164,10 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }); } }, - { detail: { tags: ['Admin'], summary: 'Price distribution' } }, + { + response: { 200: t.Array(PriceBucketSchema), ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Price distribution' }, + }, ) .get( @@ -209,6 +224,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional().default(50), }), + response: { 200: EtlResponseSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'ETL pipeline history' }, }, ) @@ -241,5 +257,8 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }); } }, - { detail: { tags: ['Admin'], summary: 'Embedding coverage' } }, + { + response: { 200: EmbeddingStatsSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Embedding coverage' }, + }, ); diff --git a/packages/api/src/routes/admin/analytics/platform.ts b/packages/api/src/routes/admin/analytics/platform.ts index 7fc0019e71..b877917f9c 100644 --- a/packages/api/src/routes/admin/analytics/platform.ts +++ b/packages/api/src/routes/admin/analytics/platform.ts @@ -7,8 +7,15 @@ import { trips, users, } from '@packrat/api/db/schema'; -import { and, count, desc, eq, gte, sql } from 'drizzle-orm'; -import { Elysia, status } from 'elysia'; +import { + ActiveUsersSchema, + ActivityPointSchema, + AdminErrorResponses, + BreakdownItemSchema, + GrowthPointSchema, +} from '@packrat/api/schemas/admin'; +import { and, count, desc, eq, gte, isNull, sql } from 'drizzle-orm'; +import { Elysia, status, t } from 'elysia'; import { z } from 'zod'; const PeriodSchema = z.object({ @@ -29,6 +36,7 @@ export const platformAnalyticsRoutes = new Elysia({ prefix: '/platform' }) analytics: { growth: '/api/admin/analytics/platform/growth', activity: '/api/admin/analytics/platform/activity', + activeUsers: '/api/admin/analytics/platform/active-users', breakdown: '/api/admin/analytics/platform/breakdown', }, })) @@ -48,7 +56,7 @@ export const platformAnalyticsRoutes = new Elysia({ prefix: '/platform' }) count: count(), }) .from(users) - .where(gte(users.createdAt, startDate)) + .where(and(isNull(users.deletedAt), gte(users.createdAt, startDate))) .groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`) .orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`), db @@ -98,6 +106,7 @@ export const platformAnalyticsRoutes = new Elysia({ prefix: '/platform' }) }, { query: PeriodSchema, + response: { 200: t.Array(GrowthPointSchema), ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Platform growth metrics' }, }, ) @@ -174,10 +183,56 @@ export const platformAnalyticsRoutes = new Elysia({ prefix: '/platform' }) }, { query: PeriodSchema, + response: { 200: t.Array(ActivityPointSchema), ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'User activity metrics' }, }, ) + .get( + '/active-users', + async () => { + const db = createDb(); + + try { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const [dau, wau, mau] = await Promise.all([ + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.lastActiveAt, oneDayAgo))), + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.lastActiveAt, sevenDaysAgo))), + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.lastActiveAt, thirtyDaysAgo))), + ]); + + return { + dau: dau[0]?.count ?? 0, + wau: wau[0]?.count ?? 0, + mau: mau[0]?.count ?? 0, + }; + } catch (error) { + console.error('Analytics active-users error:', error); + return status(500, { + error: 'Failed to fetch active user counts', + code: 'ANALYTICS_ACTIVE_USERS_ERROR', + }); + } + }, + { + response: { 200: ActiveUsersSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'DAU / WAU / MAU based on last_active_at' }, + }, + ) + .get( '/breakdown', async () => { @@ -203,5 +258,8 @@ export const platformAnalyticsRoutes = new Elysia({ prefix: '/platform' }) }); } }, - { detail: { tags: ['Admin'], summary: 'Categorical distribution metrics' } }, + { + response: { 200: t.Array(BreakdownItemSchema), ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Categorical distribution metrics' }, + }, ); diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index e95fe1ba47..8950eacf23 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -2,14 +2,25 @@ import { cors } from '@elysiajs/cors'; import { createDb } from '@packrat/api/db'; import { catalogItems, packs, users } from '@packrat/api/db/schema'; import { verifyCFAccessRequest } from '@packrat/api/middleware/cfAccess'; +import { + AdminCatalogListSchema, + AdminErrorResponses, + AdminPacksListSchema, + AdminStatsSchema, + AdminUsersListSchema, + CatalogUpdateSchema, + HardDeleteSuccessSchema, + SuccessSchema, +} from '@packrat/api/schemas/admin'; import { timingSafeEqual } from '@packrat/api/utils/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; import { assertAllDefined } from '@packrat/guards'; -import { and, count, desc, eq, ilike, or } from 'drizzle-orm'; +import { and, count, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { jwtVerify, SignJWT } from 'jose'; import { z } from 'zod'; import { analyticsRoutes } from './analytics'; +import { adminTrailsRoutes } from './trails'; const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour const ADMIN_JWT_ISSUER = 'packrat-api'; @@ -161,7 +172,10 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) async () => { const db = createDb(); try { - const [userCount] = await db.select({ count: count() }).from(users); + const [userCount] = await db + .select({ count: count() }) + .from(users) + .where(isNull(users.deletedAt)); const [packCount] = await db .select({ count: count() }) .from(packs) @@ -181,6 +195,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) } }, { + response: { 200: AdminStatsSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Get admin dashboard statistics' }, }, ) @@ -194,34 +209,54 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) const limit = Number(query.limit ?? 100); const offset = Number(query.offset ?? 0); const search = query.q; - const usersList = await db - .select({ - id: users.id, - email: users.email, - firstName: users.firstName, - lastName: users.lastName, - role: users.role, - emailVerified: users.emailVerified, - createdAt: users.createdAt, - }) - .from(users) - .where( - search - ? or( - ilike(users.email, `%${search}%`), - ilike(users.firstName, `%${search}%`), - ilike(users.lastName, `%${search}%`), - ) - : undefined, - ) - .orderBy(desc(users.createdAt)) - .limit(limit) - .offset(offset); - - return usersList.map((u) => ({ - ...u, - createdAt: u.createdAt?.toISOString() || null, - })); + const includeDeleted = query.includeDeleted === 'true'; + + const searchFilter = search + ? or( + ilike(users.email, `%${search}%`), + sql`${users.firstName} ilike ${`%${search}%`}`, + sql`${users.lastName} ilike ${`%${search}%`}`, + ) + : undefined; + + const deletedFilter = includeDeleted ? undefined : isNull(users.deletedAt); + const whereClause = + searchFilter && deletedFilter + ? and(deletedFilter, searchFilter) + : (deletedFilter ?? searchFilter); + + const [usersList, [totalRow]] = await Promise.all([ + db + .select({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + emailVerified: users.emailVerified, + createdAt: users.createdAt, + lastActiveAt: users.lastActiveAt, + deletedAt: users.deletedAt, + }) + .from(users) + .where(whereClause) + .orderBy(desc(users.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(users).where(whereClause), + ]); + + return { + data: usersList.map((u) => ({ + ...u, + createdAt: u.createdAt?.toISOString() ?? null, + lastActiveAt: u.lastActiveAt?.toISOString() ?? null, + deletedAt: u.deletedAt?.toISOString() ?? null, + })), + total: totalRow?.count ?? 0, + limit, + offset, + }; } catch (error) { console.error('Error fetching users:', error); return status(500, { error: 'Failed to fetch users', code: 'USERS_FETCH_ERROR' }); @@ -232,7 +267,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) limit: z.coerce.number().int().positive().max(100).optional(), offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), + includeDeleted: z.string().optional(), }), + response: { 200: AdminUsersListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List users' }, }, ) @@ -246,39 +283,58 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) const limit = Number(query.limit ?? 100); const offset = Number(query.offset ?? 0); const search = query.q; - const packsList = await db - .select({ - id: packs.id, - name: packs.name, - description: packs.description, - category: packs.category, - isPublic: packs.isPublic, - createdAt: packs.createdAt, - userEmail: users.email, - }) - .from(packs) - .leftJoin(users, eq(packs.userId, users.id)) - .where( - and( - eq(packs.deleted, false), - search - ? or( - ilike(packs.name, `%${search}%`), - ilike(packs.description, `%${search}%`), - ilike(packs.category, `%${search}%`), - ilike(users.email, `%${search}%`), - ) - : undefined, - ), - ) - .orderBy(desc(packs.createdAt)) - .limit(limit) - .offset(offset); - - return packsList.map((p) => ({ - ...p, - createdAt: p.createdAt?.toISOString() || null, - })); + const includeDeleted = query.includeDeleted === 'true'; + + const deletedFilter = includeDeleted ? undefined : eq(packs.deleted, false); + const searchFilter = search + ? or( + ilike(packs.name, `%${search}%`), + ilike(packs.description, `%${search}%`), + ilike(packs.category, `%${search}%`), + sql`${users.email} ilike ${`%${search}%`}`, + ) + : undefined; + const whereClause = + deletedFilter && searchFilter + ? and(deletedFilter, searchFilter) + : (deletedFilter ?? searchFilter); + + const [packsList, [totalRow]] = await Promise.all([ + db + .select({ + id: packs.id, + name: packs.name, + description: packs.description, + category: packs.category, + isPublic: packs.isPublic, + deleted: packs.deleted, + deletedAt: packs.deletedAt, + createdAt: packs.createdAt, + userEmail: users.email, + }) + .from(packs) + .leftJoin(users, eq(packs.userId, users.id)) + .where(whereClause) + .orderBy(desc(packs.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: count() }) + .from(packs) + .leftJoin(users, eq(packs.userId, users.id)) + .where(whereClause), + ]); + + return { + data: packsList.map((p) => ({ + ...p, + createdAt: p.createdAt?.toISOString() ?? null, + deletedAt: p.deletedAt?.toISOString() ?? null, + })), + total: totalRow?.count ?? 0, + limit, + offset, + }; } catch (error) { console.error('Error fetching packs:', error); return status(500, { error: 'Failed to fetch packs', code: 'PACKS_FETCH_ERROR' }); @@ -289,7 +345,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) limit: z.coerce.number().int().positive().max(100).optional(), offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), + includeDeleted: z.string().optional(), }), + response: { 200: AdminPacksListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List packs' }, }, ) @@ -303,35 +361,44 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) const limit = Number(query.limit ?? 25); const offset = Number(query.offset ?? 0); const search = query.q; - const itemsList = await db - .select({ - id: catalogItems.id, - name: catalogItems.name, - categories: catalogItems.categories, - brand: catalogItems.brand, - price: catalogItems.price, - weight: catalogItems.weight, - weightUnit: catalogItems.weightUnit, - createdAt: catalogItems.createdAt, - }) - .from(catalogItems) - .where( - search - ? or( - ilike(catalogItems.name, `%${search}%`), - ilike(catalogItems.brand, `%${search}%`), - ilike(catalogItems.description, `%${search}%`), - ) - : undefined, - ) - .orderBy(desc(catalogItems.id)) - .limit(limit) - .offset(offset); - - return itemsList.map((it) => ({ - ...it, - createdAt: it.createdAt?.toISOString() || null, - })); + + const whereClause = search + ? or( + ilike(catalogItems.name, `%${search}%`), + ilike(catalogItems.brand, `%${search}%`), + ilike(catalogItems.description, `%${search}%`), + ) + : undefined; + + const [itemsList, [totalRow]] = await Promise.all([ + db + .select({ + id: catalogItems.id, + name: catalogItems.name, + categories: catalogItems.categories, + brand: catalogItems.brand, + price: catalogItems.price, + weight: catalogItems.weight, + weightUnit: catalogItems.weightUnit, + createdAt: catalogItems.createdAt, + }) + .from(catalogItems) + .where(whereClause) + .orderBy(desc(catalogItems.id)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(catalogItems).where(whereClause), + ]); + + return { + data: itemsList.map((it) => ({ + ...it, + createdAt: it.createdAt?.toISOString() ?? null, + })), + total: totalRow?.count ?? 0, + limit, + offset, + }; } catch (error) { console.error('Error fetching catalog items:', error); return status(500, { error: 'Failed to fetch catalog items', code: 'CATALOG_FETCH_ERROR' }); @@ -343,11 +410,12 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), }), + response: { 200: AdminCatalogListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List catalog items' }, }, ) - // Delete a user + // Soft-delete a user .delete( '/users/:id', async ({ params }) => { @@ -355,20 +423,84 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) if (!Number.isFinite(id) || id <= 0) return status(400, { error: 'Invalid user id' }); const db = createDb(); try { + const updated = await db + .update(users) + .set({ deletedAt: new Date() }) + .where(and(eq(users.id, id), isNull(users.deletedAt))) + .returning(); + if (!updated.length) return status(404, { error: 'User not found or already deleted' }); + return { success: true as const }; + } catch (error) { + console.error('Error soft-deleting user:', error); + return status(500, { error: 'Failed to delete user' }); + } + }, + { + params: z.object({ id: z.string() }), + response: { 200: SuccessSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Soft-delete a user (recoverable)' }, + }, + ) + + // Hard-delete a user for compliance (GDPR / right to erasure) + .delete( + '/users/:id/hard', + async ({ params, body }) => { + const id = Number(params.id); + if (!Number.isFinite(id) || id <= 0) return status(400, { error: 'Invalid user id' }); + const db = createDb(); + try { + // Cascading FKs handle deletion of all related user data. + // Caller must supply a compliance reason for the audit log. const deleted = await db.delete(users).where(eq(users.id, id)).returning(); if (!deleted.length) return status(404, { error: 'User not found' }); - return { success: true as const }; + console.info(`[COMPLIANCE] Hard-deleted user ${id}. Reason: ${body.reason}`); + return { success: true as const, purged: true as const }; } catch (error) { if ((error as { code?: string })?.code === '23503') { - return status(409, { error: 'Cannot delete: user has dependent data' }); + return status(409, { error: 'Cannot delete: user has dependent data without cascade' }); } - console.error('Error deleting user:', error); - return status(500, { error: 'Failed to delete user' }); + console.error('Error hard-deleting user:', error); + return status(500, { error: 'Failed to hard-delete user' }); + } + }, + { + params: z.object({ id: z.string() }), + body: z.object({ + reason: z.string().min(1, 'Compliance reason is required'), + }), + response: { 200: HardDeleteSuccessSchema, ...AdminErrorResponses }, + detail: { + tags: ['Admin'], + summary: 'Hard-delete a user and all their data (irreversible, for GDPR compliance)', + }, + }, + ) + + // Restore a soft-deleted user + .post( + '/users/:id/restore', + async ({ params }) => { + const id = Number(params.id); + if (!Number.isFinite(id) || id <= 0) return status(400, { error: 'Invalid user id' }); + const db = createDb(); + try { + const restored = await db + .update(users) + .set({ deletedAt: null }) + .where(and(eq(users.id, id), sql`${users.deletedAt} IS NOT NULL`)) + .returning(); + if (!restored.length) return status(404, { error: 'User not found or not deleted' }); + return { success: true as const }; + } catch (error) { + console.error('Error restoring user:', error); + return status(500, { error: 'Failed to restore user' }); } }, { params: z.object({ id: z.string() }), - detail: { tags: ['Admin'], summary: 'Delete a user' }, + response: { 200: SuccessSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Restore a soft-deleted user' }, }, ) @@ -378,9 +510,10 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) async ({ params }) => { const db = createDb(); try { + const now = new Date(); const updated = await db .update(packs) - .set({ deleted: true }) + .set({ deleted: true, deletedAt: now }) .where(and(eq(packs.id, params.id), eq(packs.deleted, false))) .returning(); if (!updated.length) return status(404, { error: 'Pack not found' }); @@ -392,6 +525,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) }, { params: z.object({ id: z.string() }), + response: { 200: SuccessSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Soft-delete a pack' }, }, ) @@ -417,6 +551,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) }, { params: z.object({ id: z.string() }), + response: { 200: SuccessSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Delete a catalog item' }, }, ) @@ -464,7 +599,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) price: z.number().nullable().optional(), description: z.string().nullable().optional(), }), + response: { 200: CatalogUpdateSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Update a catalog item' }, }, ) - .use(analyticsRoutes); + .use(analyticsRoutes) + .use(adminTrailsRoutes); diff --git a/packages/api/src/routes/admin/trails.ts b/packages/api/src/routes/admin/trails.ts new file mode 100644 index 0000000000..232ad6bdf8 --- /dev/null +++ b/packages/api/src/routes/admin/trails.ts @@ -0,0 +1,356 @@ +import { createDb, createOsmDb } from '@packrat/api/db'; +import { trailConditionReports, users } from '@packrat/api/db/schema'; +import { + AdminErrorResponses, + SuccessSchema, + TrailConditionsListSchema, + TrailGeometrySchema, + TrailSearchItemSchema, + TrailSearchResultSchema, +} from '@packrat/api/schemas/admin'; +import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; +import { Elysia, status } from 'elysia'; +import { z } from 'zod'; + +const RouteSearchRowSchema = z.object({ + osm_id: z.string(), + name: z.string().nullable(), + sport: z.string().nullable(), + network: z.string().nullable(), + distance: z.string().nullable(), + difficulty: z.string().nullable(), + description: z.string().nullable(), + bbox: z.string().nullable(), +}); + +export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) + + /** + * GET /admin/trails/search + * + * Text + sport search over OSM routes (admin auth, no user JWT required). + */ + .get( + '/search', + async ({ query }) => { + const { q, sport, limit = 50, offset = 0 } = query; + + if (!q) { + return status(400, { error: 'Provide q (trail name to search)' }); + } + + try { + const db = createOsmDb(); + const conditions: ReturnType[] = [sql`name ILIKE ${`%${q}%`}`]; + + if (sport) conditions.push(sql`sport = ${sport}`); + + const whereClause = sql`WHERE ${conditions.reduce((acc, c) => sql`${acc} AND ${c}`)}`; + + const result = await db.execute(sql` + SELECT + osm_id::text, + name, + sport, + network, + distance, + difficulty, + description, + ST_AsGeoJSON(ST_Envelope(geometry)) AS bbox + FROM osm_routes + ${whereClause} + ORDER BY + CASE WHEN name IS NOT NULL THEN 0 ELSE 1 END, + name + LIMIT ${limit + 1} OFFSET ${offset} + `); + + const rows = z.array(RouteSearchRowSchema).parse(result.rows); + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + + return { + trails: page.map((row) => ({ + osmId: row.osm_id, + name: row.name, + sport: row.sport, + network: row.network, + distance: row.distance, + difficulty: row.difficulty, + description: row.description, + bbox: row.bbox ? JSON.parse(row.bbox) : null, + })), + hasMore, + offset, + limit, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + return status(503, { error: 'Trail features are not enabled on this server' }); + } + console.error('Admin trail search error:', error); + return status(500, { error: 'Trail search failed' }); + } + }, + { + query: z.object({ + q: z.string().min(1), + sport: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), + }), + response: { 200: TrailSearchResultSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Search OSM trails by name' }, + }, + ) + + /** + * GET /admin/trails/:osmId/geometry + */ + .get( + '/:osmId/geometry', + async ({ params }) => { + let osmId: bigint; + try { + osmId = BigInt(params.osmId); + } catch { + return status(400, { error: 'osmId must be a positive integer' }); + } + + try { + const db = createOsmDb(); + const result = await db.execute(sql` + SELECT + osm_id::text, + name, + sport, + network, + distance, + difficulty, + description, + CASE WHEN geometry IS NULL THEN members ELSE NULL END AS members, + ST_AsGeoJSON(geometry) AS geojson + FROM osm_routes + WHERE osm_id = ${osmId} + `); + + const DetailRowSchema = z.object({ + osm_id: z.string(), + name: z.string().nullable(), + sport: z.string().nullable(), + network: z.string().nullable(), + distance: z.string().nullable(), + difficulty: z.string().nullable(), + description: z.string().nullable(), + members: z + .array(z.object({ type: z.string(), ref: z.coerce.bigint(), role: z.string() })) + .nullable(), + geojson: z.string().nullable(), + }); + + const row = DetailRowSchema.nullable().parse(result.rows?.[0] ?? null); + if (!row) return status(404, { error: 'Trail not found' }); + + let geometry: unknown = null; + if (row.geojson) { + geometry = JSON.parse(row.geojson); + } else if (row.members && row.members.length > 0) { + const { stitchRouteGeometry } = await import('@packrat/api/services/trails'); + geometry = await stitchRouteGeometry(db, row.members); + } + + return { + osmId: row.osm_id, + name: row.name, + sport: row.sport, + network: row.network, + distance: row.distance, + difficulty: row.difficulty, + description: row.description, + geometry, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + return status(503, { error: 'Trail features are not enabled on this server' }); + } + console.error('Admin trail geometry error:', error); + return status(500, { error: 'Failed to fetch trail geometry' }); + } + }, + { + params: z.object({ osmId: z.string().regex(/^\d+$/) }), + response: { 200: TrailGeometrySchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Get full GeoJSON geometry for an OSM trail' }, + }, + ) + + /** + * GET /admin/trails/:osmId + * Trail metadata without geometry. + */ + .get( + '/:osmId', + async ({ params }) => { + let osmId: bigint; + try { + osmId = BigInt(params.osmId); + } catch { + return status(400, { error: 'osmId must be a positive integer' }); + } + + try { + const db = createOsmDb(); + const result = await db.execute(sql` + SELECT + osm_id::text, + name, + sport, + network, + distance, + difficulty, + description, + ST_AsGeoJSON(ST_Envelope(geometry)) AS bbox + FROM osm_routes + WHERE osm_id = ${osmId} + `); + + const row = RouteSearchRowSchema.nullable().parse(result.rows?.[0] ?? null); + if (!row) return status(404, { error: 'Trail not found' }); + + return { + osmId: row.osm_id, + name: row.name, + sport: row.sport, + network: row.network, + distance: row.distance, + difficulty: row.difficulty, + description: row.description, + bbox: row.bbox ? JSON.parse(row.bbox) : null, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + return status(503, { error: 'Trail features are not enabled on this server' }); + } + console.error('Admin trail fetch error:', error); + return status(500, { error: 'Failed to fetch trail' }); + } + }, + { + params: z.object({ osmId: z.string().regex(/^\d+$/) }), + response: { 200: TrailSearchItemSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Get OSM trail metadata by ID' }, + }, + ) + + /** + * GET /admin/trails/conditions + * All trail condition reports, newest first, with pagination. + */ + .get( + '/conditions', + async ({ query }) => { + const db = createDb(); + const limit = query.limit ?? 50; + const offset = query.offset ?? 0; + const search = query.q; + const includeDeleted = query.includeDeleted === 'true'; + + try { + const deletedFilter = includeDeleted ? undefined : eq(trailConditionReports.deleted, false); + const searchFilter = search + ? or( + ilike(trailConditionReports.trailName, `%${search}%`), + ilike(trailConditionReports.trailRegion, `%${search}%`), + ) + : undefined; + const whereClause = + deletedFilter && searchFilter + ? and(deletedFilter, searchFilter) + : (deletedFilter ?? searchFilter); + + const [reports, [totalRow]] = await Promise.all([ + db + .select({ + id: trailConditionReports.id, + trailName: trailConditionReports.trailName, + trailRegion: trailConditionReports.trailRegion, + surface: trailConditionReports.surface, + overallCondition: trailConditionReports.overallCondition, + hazards: trailConditionReports.hazards, + waterCrossings: trailConditionReports.waterCrossings, + notes: trailConditionReports.notes, + deleted: trailConditionReports.deleted, + deletedAt: trailConditionReports.deletedAt, + createdAt: trailConditionReports.createdAt, + userId: trailConditionReports.userId, + userEmail: users.email, + }) + .from(trailConditionReports) + .leftJoin(users, eq(trailConditionReports.userId, users.id)) + .where(whereClause) + .orderBy(desc(trailConditionReports.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(trailConditionReports).where(whereClause), + ]); + + return { + data: reports.map((r) => ({ + ...r, + createdAt: r.createdAt.toISOString(), + deletedAt: r.deletedAt?.toISOString() ?? null, + })), + total: totalRow?.count ?? 0, + limit, + offset, + }; + } catch (error) { + console.error('Admin trail conditions error:', error); + return status(500, { error: 'Failed to fetch trail conditions' }); + } + }, + { + query: z.object({ + q: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), + includeDeleted: z.string().optional(), + }), + response: { 200: TrailConditionsListSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'List all trail condition reports' }, + }, + ) + + /** + * DELETE /admin/trails/conditions/:reportId + * Soft-delete a trail condition report. + */ + .delete( + '/conditions/:reportId', + async ({ params }) => { + const db = createDb(); + try { + const now = new Date(); + const updated = await db + .update(trailConditionReports) + .set({ deleted: true, deletedAt: now }) + .where( + and( + eq(trailConditionReports.id, params.reportId), + eq(trailConditionReports.deleted, false), + ), + ) + .returning(); + if (!updated.length) return status(404, { error: 'Report not found' }); + return { success: true as const }; + } catch (error) { + console.error('Admin trail condition delete error:', error); + return status(500, { error: 'Failed to delete report' }); + } + }, + { + params: z.object({ reportId: z.string() }), + response: { 200: SuccessSchema, ...AdminErrorResponses }, + detail: { tags: ['Admin'], summary: 'Soft-delete a trail condition report' }, + }, + ); diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts index f0fd9873b1..9229623482 100644 --- a/packages/api/src/routes/trails/index.ts +++ b/packages/api/src/routes/trails/index.ts @@ -9,7 +9,7 @@ import { z } from 'zod'; const OsmMemberSchema = z.object({ type: z.string(), - ref: z.number(), + ref: z.coerce.bigint(), role: z.string(), }); diff --git a/packages/api/src/schemas/admin.ts b/packages/api/src/schemas/admin.ts new file mode 100644 index 0000000000..2107ba50d3 --- /dev/null +++ b/packages/api/src/schemas/admin.ts @@ -0,0 +1,208 @@ +import { t } from 'elysia'; + +// t.Unsafe keeps the JSON Schema shape for OpenAPI docs while giving +// TypeScript type `any`. ElysiaCustomStatusResponse has T in both covariant +// and contravariant positions (invariant), so a literal error body literal +// can never unify with { error: string } without `any` bypassing invariance. +// biome-ignore lint/suspicious/noExplicitAny: intentional — see comment above +const Err = t.Unsafe(t.Object({ error: t.String() }, { additionalProperties: true })); +export const AdminErrorResponses = { + 400: Err, + 401: Err, + 404: Err, + 409: Err, + 429: Err, + 500: Err, + 503: Err, +} as const; + +// ─── Stats ──────────────────────────────────────────────────────────────────── + +export const AdminStatsSchema = t.Object({ + users: t.Number(), + packs: t.Number(), + items: t.Number(), +}); + +// ─── Users ──────────────────────────────────────────────────────────────────── + +export const AdminUserItemSchema = t.Object({ + id: t.Number(), + email: t.String(), + firstName: t.Nullable(t.String()), + lastName: t.Nullable(t.String()), + role: t.Nullable(t.String()), + emailVerified: t.Nullable(t.Boolean()), + createdAt: t.Nullable(t.String()), + lastActiveAt: t.Nullable(t.String()), + deletedAt: t.Nullable(t.String()), +}); + +// ─── Packs ──────────────────────────────────────────────────────────────────── + +export const AdminPackItemSchema = t.Object({ + id: t.String(), + name: t.String(), + description: t.Nullable(t.String()), + category: t.String(), + isPublic: t.Nullable(t.Boolean()), + deleted: t.Boolean(), + deletedAt: t.Nullable(t.String()), + createdAt: t.Nullable(t.String()), + userEmail: t.Nullable(t.String()), +}); + +// ─── Catalog ───────────────────────────────────────────────────────────────── + +export const AdminCatalogItemSchema = t.Object({ + id: t.Number(), + name: t.String(), + categories: t.Nullable(t.Array(t.String())), + brand: t.Nullable(t.String()), + price: t.Nullable(t.Number()), + weight: t.Number(), + weightUnit: t.String(), + createdAt: t.Nullable(t.String()), +}); + +// ─── Paginated wrappers ─────────────────────────────────────────────────────── + +const Paginated = >(item: T) => + t.Object({ data: t.Array(item), total: t.Number(), limit: t.Number(), offset: t.Number() }); + +export const AdminUsersListSchema = Paginated(AdminUserItemSchema); +export const AdminPacksListSchema = Paginated(AdminPackItemSchema); +export const AdminCatalogListSchema = Paginated(AdminCatalogItemSchema); + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +export const SuccessSchema = t.Object({ success: t.Literal(true) }); +export const HardDeleteSuccessSchema = t.Object({ + success: t.Literal(true), + purged: t.Literal(true), +}); +export const CatalogUpdateSchema = t.Object({ id: t.Number(), name: t.String() }); + +// ─── Analytics — Platform ───────────────────────────────────────────────────── + +export const GrowthPointSchema = t.Object({ + period: t.String(), + users: t.Number(), + packs: t.Number(), + catalogItems: t.Number(), +}); + +export const ActivityPointSchema = t.Object({ + period: t.String(), + trips: t.Number(), + trailReports: t.Number(), + posts: t.Number(), +}); + +export const ActiveUsersSchema = t.Object({ dau: t.Number(), wau: t.Number(), mau: t.Number() }); + +export const BreakdownItemSchema = t.Object({ category: t.String(), count: t.Number() }); + +// ─── Analytics — Catalog ───────────────────────────────────────────────────── + +export const CatalogOverviewSchema = t.Object({ + totalItems: t.Number(), + totalBrands: t.Number(), + avgPrice: t.Nullable(t.Number()), + minPrice: t.Nullable(t.Number()), + maxPrice: t.Nullable(t.Number()), + embeddingCoverage: t.Object({ total: t.Number(), withEmbedding: t.Number(), pct: t.Number() }), + availability: t.Array(t.Object({ status: t.Nullable(t.String()), count: t.Number() })), + addedLast30Days: t.Number(), +}); + +export const BrandRowSchema = t.Object({ + brand: t.String(), + itemCount: t.Number(), + avgPrice: t.Nullable(t.Number()), + minPrice: t.Nullable(t.Number()), + maxPrice: t.Nullable(t.Number()), + avgRating: t.Nullable(t.Number()), +}); + +export const PriceBucketSchema = t.Object({ bucket: t.String(), count: t.Number() }); + +export const EtlJobSchema = t.Object({ + id: t.String(), + status: t.Union([t.Literal('running'), t.Literal('completed'), t.Literal('failed')]), + source: t.String(), + filename: t.String(), + scraperRevision: t.String(), + startedAt: t.String(), + completedAt: t.Nullable(t.String()), + totalProcessed: t.Nullable(t.Number()), + totalValid: t.Nullable(t.Number()), + totalInvalid: t.Nullable(t.Number()), + successRate: t.Nullable(t.Number()), +}); + +export const EtlResponseSchema = t.Object({ + jobs: t.Array(EtlJobSchema), + summary: t.Object({ + totalRuns: t.Number(), + completed: t.Number(), + failed: t.Number(), + totalItemsIngested: t.Number(), + }), +}); + +export const EmbeddingStatsSchema = t.Object({ + total: t.Number(), + withEmbedding: t.Number(), + pending: t.Number(), + coveragePct: t.Number(), +}); + +// ─── Trails ─────────────────────────────────────────────────────────────────── + +export const TrailSearchItemSchema = t.Object({ + osmId: t.String(), + name: t.Nullable(t.String()), + sport: t.Nullable(t.String()), + network: t.Nullable(t.String()), + distance: t.Nullable(t.String()), + difficulty: t.Nullable(t.String()), + description: t.Nullable(t.String()), + bbox: t.Nullable(t.Unknown()), +}); + +export const TrailSearchResultSchema = t.Object({ + trails: t.Array(TrailSearchItemSchema), + hasMore: t.Boolean(), + offset: t.Number(), + limit: t.Number(), +}); + +export const TrailGeometrySchema = t.Object({ + osmId: t.String(), + name: t.Nullable(t.String()), + sport: t.Nullable(t.String()), + network: t.Nullable(t.String()), + distance: t.Nullable(t.String()), + difficulty: t.Nullable(t.String()), + description: t.Nullable(t.String()), + geometry: t.Nullable(t.Unknown()), +}); + +export const TrailConditionReportSchema = t.Object({ + id: t.String(), + trailName: t.String(), + trailRegion: t.Nullable(t.String()), + surface: t.String(), + overallCondition: t.String(), + hazards: t.Array(t.String()), + waterCrossings: t.Number(), + notes: t.Nullable(t.String()), + deleted: t.Boolean(), + deletedAt: t.Nullable(t.String()), + createdAt: t.String(), + userId: t.Number(), + userEmail: t.Nullable(t.String()), +}); + +export const TrailConditionsListSchema = Paginated(TrailConditionReportSchema); diff --git a/packages/api/src/services/trails.ts b/packages/api/src/services/trails.ts index 4f48f5e836..4dfc8b0cb7 100644 --- a/packages/api/src/services/trails.ts +++ b/packages/api/src/services/trails.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; const OsmMemberSchema = z.object({ type: z.string(), - ref: z.number(), + ref: z.coerce.bigint(), role: z.string(), }); diff --git a/packages/api/src/utils/__tests__/compute-pack.test.ts b/packages/api/src/utils/__tests__/compute-pack.test.ts index ae7a8b2c0f..5df1bba21f 100644 --- a/packages/api/src/utils/__tests__/compute-pack.test.ts +++ b/packages/api/src/utils/__tests__/compute-pack.test.ts @@ -17,6 +17,7 @@ function makePack(overrides: Partial = {}): PackWithItems { image: null, tags: [], deleted: false, + deletedAt: null, isAIGenerated: false, localCreatedAt: new Date(), localUpdatedAt: new Date(), @@ -39,6 +40,7 @@ function makePackItem( packId: 'pack-1', userId: 1, deleted: false, + deletedAt: null, isAIGenerated: false, category: null, description: null, diff --git a/packages/api/test/packs.test.ts b/packages/api/test/packs.test.ts index 65d61309df..9b058f5815 100644 --- a/packages/api/test/packs.test.ts +++ b/packages/api/test/packs.test.ts @@ -54,6 +54,7 @@ vi.mock('@packrat/api/services/packService', async () => { localCreatedAt: new Date(), localUpdatedAt: new Date(), deleted: false, + deletedAt: null, }); } return mockPacks;