diff --git a/CLAUDE.md b/CLAUDE.md index 1973f9f..5feba50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ - `bun run format` — format all files with Prettier ## API Conventions -- All list endpoints support server-side pagination, search, and sorting via query params: +- **Every endpoint that returns a list must support pagination, search, and sorting** — no exceptions unless the endpoint is explicitly a lightweight lookup (see below) - `?page=1&limit=25` — pagination (default: page 1, 25 per page, max 100) - `?q=search+term` — full-text search across relevant columns - `?sort=name&order=asc` — sorting by field name, asc or desc @@ -48,6 +48,15 @@ - Search and filtering is ALWAYS server-side, never client-side - Use `PaginationSchema` from `@forte/shared/schemas` to parse query params - Use pagination helpers from `packages/backend/src/utils/pagination.ts` +- **Lookup endpoints** (e.g., `/roles/all`, `/statuses/all`) are the exception — these return a flat unpaginated list for populating dropdowns/selects. Use a `/all` suffix to distinguish from the paginated list endpoint for the same resource. + +## Frontend Table Conventions +- **Every table that displays data must use the shared `DataTable` component** (`components/shared/data-table.tsx`) +- All tables must support: **search** (via `?q=`), **sortable columns**, and **server-side pagination** +- Use the `usePagination()` hook (`hooks/use-pagination.ts`) — it manages page, search, and sort state via URL params +- All data columns that make sense to sort by should be sortable (e.g., name, email, date, status) — don't limit to just 1-2 columns +- Sub-resource tables (e.g., members within an account, payment methods) follow the same rules — use `DataTable` with pagination, not raw `` with unbounded queries +- Loading states should use skeleton loading (built into `DataTable`), not plain "Loading..." text ## Conventions - Shared Zod schemas are the single source of truth for validation (used on both frontend and backend) diff --git a/packages/admin/src/api/identifiers.ts b/packages/admin/src/api/identifiers.ts index d005682..a858db8 100644 --- a/packages/admin/src/api/identifiers.ts +++ b/packages/admin/src/api/identifiers.ts @@ -1,6 +1,7 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' import type { MemberIdentifier } from '@/types/account' +import type { PaginatedResponse } from '@forte/shared/schemas' export const identifierKeys = { all: (memberId: string) => ['members', memberId, 'identifiers'] as const, @@ -9,7 +10,7 @@ export const identifierKeys = { export function identifierListOptions(memberId: string) { return queryOptions({ queryKey: identifierKeys.all(memberId), - queryFn: () => api.get<{ data: MemberIdentifier[] }>(`/v1/members/${memberId}/identifiers`), + queryFn: () => api.get>(`/v1/members/${memberId}/identifiers`, { page: 1, limit: 100, order: 'asc' }), }) } diff --git a/packages/admin/src/api/payment-methods.ts b/packages/admin/src/api/payment-methods.ts index 218cc96..2367368 100644 --- a/packages/admin/src/api/payment-methods.ts +++ b/packages/admin/src/api/payment-methods.ts @@ -1,15 +1,17 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' import type { PaymentMethod } from '@/types/account' +import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' export const paymentMethodKeys = { all: (accountId: string) => ['accounts', accountId, 'payment-methods'] as const, + list: (accountId: string, params: PaginationInput) => [...paymentMethodKeys.all(accountId), params] as const, } -export function paymentMethodListOptions(accountId: string) { +export function paymentMethodListOptions(accountId: string, params: PaginationInput) { return queryOptions({ - queryKey: paymentMethodKeys.all(accountId), - queryFn: () => api.get<{ data: PaymentMethod[] }>(`/v1/accounts/${accountId}/payment-methods`), + queryKey: paymentMethodKeys.list(accountId, params), + queryFn: () => api.get>(`/v1/accounts/${accountId}/payment-methods`, params), }) } diff --git a/packages/admin/src/api/processor-links.ts b/packages/admin/src/api/processor-links.ts index 316ea56..66743b6 100644 --- a/packages/admin/src/api/processor-links.ts +++ b/packages/admin/src/api/processor-links.ts @@ -1,15 +1,17 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' import type { ProcessorLink } from '@/types/account' +import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' export const processorLinkKeys = { all: (accountId: string) => ['accounts', accountId, 'processor-links'] as const, + list: (accountId: string, params: PaginationInput) => [...processorLinkKeys.all(accountId), params] as const, } -export function processorLinkListOptions(accountId: string) { +export function processorLinkListOptions(accountId: string, params: PaginationInput) { return queryOptions({ - queryKey: processorLinkKeys.all(accountId), - queryFn: () => api.get<{ data: ProcessorLink[] }>(`/v1/accounts/${accountId}/processor-links`), + queryKey: processorLinkKeys.list(accountId, params), + queryFn: () => api.get>(`/v1/accounts/${accountId}/processor-links`, params), }) } diff --git a/packages/admin/src/api/rbac.ts b/packages/admin/src/api/rbac.ts index a47d52c..9db3e4a 100644 --- a/packages/admin/src/api/rbac.ts +++ b/packages/admin/src/api/rbac.ts @@ -1,10 +1,12 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' import type { Permission, Role } from '@/types/rbac' +import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas' export const rbacKeys = { permissions: ['permissions'] as const, roles: ['roles'] as const, + roleList: (params: PaginationInput) => ['roles', 'list', params] as const, role: (id: string) => ['roles', id] as const, userRoles: (userId: string) => ['users', userId, 'roles'] as const, myPermissions: ['me', 'permissions'] as const, @@ -17,10 +19,19 @@ export function permissionListOptions() { }) } +/** All roles (for dropdowns, selectors) */ export function roleListOptions() { return queryOptions({ queryKey: rbacKeys.roles, - queryFn: () => api.get<{ data: Role[] }>('/v1/roles'), + queryFn: () => api.get<{ data: Role[] }>('/v1/roles/all'), + }) +} + +/** Paginated roles (for the roles list page) */ +export function rolePageOptions(params: PaginationInput) { + return queryOptions({ + queryKey: rbacKeys.roleList(params), + queryFn: () => api.get>('/v1/roles', params as Record), }) } diff --git a/packages/admin/src/api/tax-exemptions.ts b/packages/admin/src/api/tax-exemptions.ts index 713a98c..418bde9 100644 --- a/packages/admin/src/api/tax-exemptions.ts +++ b/packages/admin/src/api/tax-exemptions.ts @@ -1,15 +1,17 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' import type { TaxExemption } from '@/types/account' +import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas' export const taxExemptionKeys = { all: (accountId: string) => ['accounts', accountId, 'tax-exemptions'] as const, + list: (accountId: string, params: PaginationInput) => [...taxExemptionKeys.all(accountId), params] as const, } -export function taxExemptionListOptions(accountId: string) { +export function taxExemptionListOptions(accountId: string, params: PaginationInput) { return queryOptions({ - queryKey: taxExemptionKeys.all(accountId), - queryFn: () => api.get<{ data: TaxExemption[] }>(`/v1/accounts/${accountId}/tax-exemptions`), + queryKey: taxExemptionKeys.list(accountId, params), + queryFn: () => api.get>(`/v1/accounts/${accountId}/tax-exemptions`, params), }) } diff --git a/packages/admin/src/api/users.ts b/packages/admin/src/api/users.ts index b13ffc4..f24ac69 100644 --- a/packages/admin/src/api/users.ts +++ b/packages/admin/src/api/users.ts @@ -1,5 +1,13 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' +import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas' + +export interface UserRole { + id: string + name: string + slug: string + isSystem: boolean +} export interface UserRecord { id: string @@ -7,16 +15,31 @@ export interface UserRecord { firstName: string lastName: string role: string + isActive: boolean createdAt: string + roles: UserRole[] } export const userKeys = { + list: (params: PaginationInput) => ['users', params] as const, roles: (userId: string) => ['users', userId, 'roles'] as const, } +export function userListOptions(params: PaginationInput) { + return queryOptions({ + queryKey: userKeys.list(params), + queryFn: () => api.get>('/v1/users', params as Record), + }) +} + export function userRolesOptions(userId: string) { return queryOptions({ queryKey: userKeys.roles(userId), - queryFn: () => api.get<{ data: { id: string; name: string; slug: string; isSystem: boolean }[] }>(`/v1/users/${userId}/roles`), + queryFn: () => api.get<{ data: UserRole[] }>(`/v1/users/${userId}/roles`), }) } + +export const userMutations = { + toggleStatus: (userId: string, isActive: boolean) => + api.patch<{ id: string; isActive: boolean }>(`/v1/users/${userId}/status`, { isActive }), +} diff --git a/packages/admin/src/components/accounts/identifier-form.tsx b/packages/admin/src/components/accounts/identifier-form.tsx index bdc386c..a11d97a 100644 --- a/packages/admin/src/components/accounts/identifier-form.tsx +++ b/packages/admin/src/components/accounts/identifier-form.tsx @@ -1,12 +1,12 @@ import { useForm } from 'react-hook-form' -import { useRef } from 'react' +import { useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { MemberIdentifier } from '@/types/account' -import { Upload } from 'lucide-react' +import { Upload, X } from 'lucide-react' const ID_TYPES = [ { value: 'drivers_license', label: "Driver's License / State ID" }, @@ -14,22 +14,18 @@ const ID_TYPES = [ { value: 'school_id', label: 'School ID' }, ] +export interface IdentifierFiles { + front?: File + back?: File +} + interface IdentifierFormProps { memberId: string defaultValues?: Partial - onSubmit: (data: Record) => void + onSubmit: (data: Record, files: IdentifierFiles) => void loading?: boolean } -function fileToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(file) - }) -} - export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: IdentifierFormProps) { const { register, handleSubmit, setValue, watch } = useForm({ defaultValues: { @@ -41,20 +37,38 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I expiresAt: defaultValues?.expiresAt ?? '', notes: defaultValues?.notes ?? '', isPrimary: defaultValues?.isPrimary ?? false, - imageFront: defaultValues?.imageFront ?? '', - imageBack: defaultValues?.imageBack ?? '', }, }) const frontInputRef = useRef(null) const backInputRef = useRef(null) - const imageFront = watch('imageFront') - const imageBack = watch('imageBack') + const [frontFile, setFrontFile] = useState(null) + const [backFile, setBackFile] = useState(null) + const [frontPreview, setFrontPreview] = useState(null) + const [backPreview, setBackPreview] = useState(null) const idType = watch('type') - async function handleFileSelect(field: 'imageFront' | 'imageBack', file: File) { - const base64 = await fileToBase64(file) - setValue(field, base64) + function handleFileSelect(side: 'front' | 'back', file: File) { + const url = URL.createObjectURL(file) + if (side === 'front') { + setFrontFile(file) + setFrontPreview(url) + } else { + setBackFile(file) + setBackPreview(url) + } + } + + function clearFile(side: 'front' | 'back') { + if (side === 'front') { + if (frontPreview) URL.revokeObjectURL(frontPreview) + setFrontFile(null) + setFrontPreview(null) + } else { + if (backPreview) URL.revokeObjectURL(backPreview) + setBackFile(null) + setBackPreview(null) + } } function handleFormSubmit(data: Record) { @@ -62,14 +76,17 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I for (const [key, value] of Object.entries(data)) { cleaned[key] = value === '' ? undefined : value } - onSubmit(cleaned) + onSubmit(cleaned, { + front: frontFile ?? undefined, + back: backFile ?? undefined, + }) } return (
- setValue('type', v as 'drivers_license' | 'passport' | 'school_id')}> @@ -114,21 +131,21 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I e.target.files?.[0] && handleFileSelect('imageFront', e.target.files[0])} + onChange={(e) => e.target.files?.[0] && handleFileSelect('front', e.target.files[0])} /> - {imageFront ? ( + {frontPreview ? (
- ID front + ID front
) : ( @@ -142,21 +159,21 @@ export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: I e.target.files?.[0] && handleFileSelect('imageBack', e.target.files[0])} + onChange={(e) => e.target.files?.[0] && handleFileSelect('back', e.target.files[0])} /> - {imageBack ? ( + {backPreview ? (
- ID back + ID back
) : ( diff --git a/packages/admin/src/components/accounts/payment-method-form.tsx b/packages/admin/src/components/accounts/payment-method-form.tsx index 4f6bfbd..658de88 100644 --- a/packages/admin/src/components/accounts/payment-method-form.tsx +++ b/packages/admin/src/components/accounts/payment-method-form.tsx @@ -20,7 +20,7 @@ export function PaymentMethodForm({ accountId, onSubmit, loading }: PaymentMetho setValue, watch, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(PaymentMethodCreateSchema), defaultValues: { accountId, diff --git a/packages/admin/src/components/accounts/tax-exemption-form.tsx b/packages/admin/src/components/accounts/tax-exemption-form.tsx index a4268e6..e7b881f 100644 --- a/packages/admin/src/components/accounts/tax-exemption-form.tsx +++ b/packages/admin/src/components/accounts/tax-exemption-form.tsx @@ -18,7 +18,7 @@ export function TaxExemptionForm({ accountId, onSubmit, loading }: TaxExemptionF register, handleSubmit, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(TaxExemptionCreateSchema), defaultValues: { accountId, diff --git a/packages/admin/src/components/shared/avatar-upload.tsx b/packages/admin/src/components/shared/avatar-upload.tsx new file mode 100644 index 0000000..7e49046 --- /dev/null +++ b/packages/admin/src/components/shared/avatar-upload.tsx @@ -0,0 +1,146 @@ +import { useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { useAuthStore } from '@/stores/auth.store' +import { Button } from '@/components/ui/button' +import { Camera, User } from 'lucide-react' +import { toast } from 'sonner' + +interface FileRecord { + id: string + path: string + url: string + filename: string +} + +function entityFilesOptions(entityType: string, entityId: string) { + return queryOptions({ + queryKey: ['files', entityType, entityId], + queryFn: () => api.get<{ data: FileRecord[] }>('/v1/files', { entityType, entityId }), + enabled: !!entityId, + }) +} + +interface AvatarUploadProps { + entityType: 'user' | 'member' + entityId: string + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'h-8 w-8', + md: 'h-16 w-16', + lg: 'h-24 w-24', +} + +const iconSizes = { + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', +} + +export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUploadProps) { + const queryClient = useQueryClient() + const token = useAuthStore((s) => s.token) + const fileInputRef = useRef(null) + const [uploading, setUploading] = useState(false) + + const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId)) + + // Find profile image from files + const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-')) + const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null + + async function handleUpload(file: File) { + setUploading(true) + try { + const formData = new FormData() + formData.append('file', file) + formData.append('entityType', entityType) + formData.append('entityId', entityId) + formData.append('category', 'profile') + + // Delete existing profile image first + if (profileFile) { + await api.del(`/v1/files/${profileFile.id}`) + } + + const res = await fetch('/v1/files', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + + if (!res.ok) { + const err = await res.json() + throw new Error(err.error?.message ?? 'Upload failed') + } + + queryClient.invalidateQueries({ queryKey: ['files', entityType, entityId] }) + toast.success('Profile picture updated') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed') + } finally { + setUploading(false) + } + } + + function handleFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (file) handleUpload(file) + // Reset input so same file can be re-selected + e.target.value = '' + } + + return ( +
+
+ {imageUrl ? ( + Profile + ) : ( + + )} +
+ + +
+ ) +} + +/** Display-only avatar (no upload button) */ +export function Avatar({ entityType, entityId, size = 'sm' }: AvatarUploadProps) { + const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId)) + const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-')) + const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null + + return ( +
+ {imageUrl ? ( + Profile + ) : ( + + )} +
+ ) +} diff --git a/packages/admin/src/hooks/use-pagination.ts b/packages/admin/src/hooks/use-pagination.ts index 4d3636e..d278e5f 100644 --- a/packages/admin/src/hooks/use-pagination.ts +++ b/packages/admin/src/hooks/use-pagination.ts @@ -23,12 +23,12 @@ export function usePagination() { function setParams(updates: Partial) { navigate({ - search: (prev: PaginationSearch) => ({ + search: ((prev: PaginationSearch) => ({ ...prev, ...updates, // Reset to page 1 when search or sort changes page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? prev.page), - }), + })) as any, replace: true, }) } diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index bccad3a..1fa94b3 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -1,5 +1,9 @@ import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' import { useAuthStore } from '@/stores/auth.store' +import { myPermissionsOptions } from '@/api/rbac' +import { Avatar } from '@/components/shared/avatar-upload' import { Button } from '@/components/ui/button' import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User } from 'lucide-react' @@ -13,16 +17,48 @@ export const Route = createFileRoute('/_authenticated')({ component: AuthenticatedLayout, }) +function NavLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) { + return ( + + {icon} + {label} + + ) +} + function AuthenticatedLayout() { const router = useRouter() const user = useAuthStore((s) => s.user) const logout = useAuthStore((s) => s.logout) + const hasPermission = useAuthStore((s) => s.hasPermission) + const setPermissions = useAuthStore((s) => s.setPermissions) + const permissionsLoaded = useAuthStore((s) => s.permissionsLoaded) + + // Fetch permissions on mount + const { data: permData } = useQuery({ + ...myPermissionsOptions(), + enabled: !!useAuthStore.getState().token, + }) + + useEffect(() => { + if (permData?.permissions) { + setPermissions(permData.permissions) + } + }, [permData, setPermissions]) function handleLogout() { logout() router.navigate({ to: '/login', replace: true }) } + const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view') + const canViewUsers = !permissionsLoaded || hasPermission('users.view') + return (
@@ -31,59 +67,37 @@ function AuthenticatedLayout() {

Forte

+ {/* Sidebar links use `as any` on search because TanStack Router + requires the full validated search shape, but these links just + navigate to the page with default params. */}
- - - Accounts - - - - Members - -
- Admin -
- - - Users - - - - Roles - - - - Help - + {canViewAccounts && ( + <> + } label="Accounts" /> + } label="Members" /> + + )} + {canViewUsers && ( +
+ Admin +
+ )} + {canViewUsers && ( + <> + } label="Users" /> + } label="Roles" /> + + )}
+ } label="Help" /> - + {user?.id ? : } {user?.firstName} {user?.lastName}
@@ -96,7 +105,7 @@ function MemberIdentifiers({ memberId }: { memberId: string }) {
- {(id.imageFront || id.imageBack) && ( + {(id.imageFrontFileId || id.imageBackFileId) && ( Has images )} + + +
# - Name - Email + + Phone Status - {members.map((m) => ( - <> - - - - - {m.memberNumber ?? '-'} - {m.firstName} {m.lastName} - {m.email ?? '-'} - {m.phone ?? '-'} - - {m.isMinor ? Minor : Adult} - - - - - - - - navigate({ - to: '/members/$memberId', - params: { memberId: m.id }, - })}> - - Edit - - setExpandedMember(expandedMember === m.id ? null : m.id)}> - - {expandedMember === m.id ? 'Hide IDs' : 'View IDs'} - - - deleteMutation.mutate(m.id)}> - - Delete - - - - - - {expandedMember === m.id && ( - - - + {members.length === 0 ? ( + + + No members found + + + ) : ( + members.map((m) => ( + <> + + + + + {m.memberNumber ?? '-'} + {m.firstName} {m.lastName} + {m.email ?? '-'} + {m.phone ?? '-'} + + {m.isMinor ? Minor : Adult} + + + + + + + + navigate({ + to: '/members/$memberId', + params: { memberId: m.id }, + })}> + + Edit + + setExpandedMember(expandedMember === m.id ? null : m.id)}> + + {expandedMember === m.id ? 'Hide IDs' : 'View IDs'} + + + deleteMutation.mutate(m.id)}> + + Delete + + + - )} - - ))} + {expandedMember === m.id && ( + + + + + + )} + + )) + )}
- )} + +
+ {total} total +
+ + + Page {params.page} of {totalPages} + + +
+
+ ) } diff --git a/packages/admin/src/routes/_authenticated/accounts/$accountId/payment-methods.tsx b/packages/admin/src/routes/_authenticated/accounts/$accountId/payment-methods.tsx index a5941d5..e06c94c 100644 --- a/packages/admin/src/routes/_authenticated/accounts/$accountId/payment-methods.tsx +++ b/packages/admin/src/routes/_authenticated/accounts/$accountId/payment-methods.tsx @@ -3,14 +3,24 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { paymentMethodListOptions, paymentMethodMutations, paymentMethodKeys } from '@/api/payment-methods' import { PaymentMethodForm } from '@/components/accounts/payment-method-form' +import { usePagination } from '@/hooks/use-pagination' +import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, Trash2, Star } from 'lucide-react' +import { Plus, Trash2, Star, Search } from 'lucide-react' import { toast } from 'sonner' +import type { PaymentMethod } from '@/types/account' export const Route = createFileRoute('/_authenticated/accounts/$accountId/payment-methods')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 25, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), component: PaymentMethodsTab, }) @@ -18,8 +28,10 @@ function PaymentMethodsTab() { const { accountId } = useParams({ from: '/_authenticated/accounts/$accountId/payment-methods' }) const queryClient = useQueryClient() const [dialogOpen, setDialogOpen] = useState(false) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') - const { data, isLoading } = useQuery(paymentMethodListOptions(accountId)) + const { data, isLoading } = useQuery(paymentMethodListOptions(accountId, params)) const createMutation = useMutation({ mutationFn: (data: Record) => paymentMethodMutations.create(accountId, data), @@ -49,7 +61,56 @@ function PaymentMethodsTab() { onError: (err) => toast.error(err.message), }) - const methods = data?.data ?? [] + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + const columns: Column[] = [ + { + key: 'card_brand', + header: 'Card', + sortable: true, + render: (m) => {m.cardBrand ?? 'Card'} {m.lastFour ? `****${m.lastFour}` : ''}, + }, + { + key: 'processor', + header: 'Processor', + sortable: true, + render: (m) => {m.processor}, + }, + { + key: 'expires', + header: 'Expires', + render: (m) => <>{m.expMonth && m.expYear ? `${m.expMonth}/${m.expYear}` : '-'}, + }, + { + key: 'status', + header: 'Status', + render: (m) => ( +
+ {m.isDefault && Default} + {m.requiresUpdate && Needs Update} +
+ ), + }, + { + key: 'actions', + header: 'Actions', + render: (m) => ( +
+ {!m.isDefault && ( + + )} + +
+ ), + }, + ] return (
@@ -66,52 +127,31 @@ function PaymentMethodsTab() {
- {isLoading ? ( -

Loading...

- ) : methods.length === 0 ? ( -

No payment methods

- ) : ( -
- - - - Card - Processor - Expires - Status - Actions - - - - {methods.map((m) => ( - - - {m.cardBrand ?? 'Card'} {m.lastFour ? `****${m.lastFour}` : ''} - - {m.processor} - {m.expMonth && m.expYear ? `${m.expMonth}/${m.expYear}` : '-'} - - {m.isDefault && Default} - {m.requiresUpdate && Needs Update} - - -
- {!m.isDefault && ( - - )} - -
-
-
- ))} -
-
+
+
+ + setSearchInput(e.target.value)} + className="pl-9" + />
- )} + +
+ +
) } diff --git a/packages/admin/src/routes/_authenticated/accounts/$accountId/processor-links.tsx b/packages/admin/src/routes/_authenticated/accounts/$accountId/processor-links.tsx index 56949a4..c7727f0 100644 --- a/packages/admin/src/routes/_authenticated/accounts/$accountId/processor-links.tsx +++ b/packages/admin/src/routes/_authenticated/accounts/$accountId/processor-links.tsx @@ -3,14 +3,24 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { processorLinkListOptions, processorLinkMutations, processorLinkKeys } from '@/api/processor-links' import { ProcessorLinkForm } from '@/components/accounts/processor-link-form' +import { usePagination } from '@/hooks/use-pagination' +import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, Trash2 } from 'lucide-react' +import { Plus, Trash2, Search } from 'lucide-react' import { toast } from 'sonner' +import type { ProcessorLink } from '@/types/account' export const Route = createFileRoute('/_authenticated/accounts/$accountId/processor-links')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 25, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), component: ProcessorLinksTab, }) @@ -18,8 +28,10 @@ function ProcessorLinksTab() { const { accountId } = useParams({ from: '/_authenticated/accounts/$accountId/processor-links' }) const queryClient = useQueryClient() const [dialogOpen, setDialogOpen] = useState(false) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') - const { data, isLoading } = useQuery(processorLinkListOptions(accountId)) + const { data, isLoading } = useQuery(processorLinkListOptions(accountId, params)) const createMutation = useMutation({ mutationFn: (data: Record) => processorLinkMutations.create(accountId, data), @@ -40,7 +52,38 @@ function ProcessorLinksTab() { onError: (err) => toast.error(err.message), }) - const links = data?.data ?? [] + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + const columns: Column[] = [ + { + key: 'processor', + header: 'Processor', + sortable: true, + render: (l) => {l.processor}, + }, + { + key: 'customer_id', + header: 'Customer ID', + render: (l) => {l.processorCustomerId}, + }, + { + key: 'status', + header: 'Status', + render: (l) => l.isActive ? Active : Inactive, + }, + { + key: 'actions', + header: 'Actions', + render: (l) => ( + + ), + }, + ] return (
@@ -57,40 +100,31 @@ function ProcessorLinksTab() {
- {isLoading ? ( -

Loading...

- ) : links.length === 0 ? ( -

No processor links

- ) : ( -
- - - - Processor - Customer ID - Status - Actions - - - - {links.map((l) => ( - - {l.processor} - {l.processorCustomerId} - - {l.isActive ? Active : Inactive} - - - - - - ))} - -
+
+
+ + setSearchInput(e.target.value)} + className="pl-9" + />
- )} + +
+ +
) } diff --git a/packages/admin/src/routes/_authenticated/accounts/$accountId/tax-exemptions.tsx b/packages/admin/src/routes/_authenticated/accounts/$accountId/tax-exemptions.tsx index 6c961d9..63f2a40 100644 --- a/packages/admin/src/routes/_authenticated/accounts/$accountId/tax-exemptions.tsx +++ b/packages/admin/src/routes/_authenticated/accounts/$accountId/tax-exemptions.tsx @@ -3,14 +3,24 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { taxExemptionListOptions, taxExemptionMutations, taxExemptionKeys } from '@/api/tax-exemptions' import { TaxExemptionForm } from '@/components/accounts/tax-exemption-form' +import { usePagination } from '@/hooks/use-pagination' +import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, Check, X } from 'lucide-react' +import { Plus, Check, X, Search } from 'lucide-react' import { toast } from 'sonner' +import type { TaxExemption } from '@/types/account' export const Route = createFileRoute('/_authenticated/accounts/$accountId/tax-exemptions')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 25, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), component: TaxExemptionsTab, }) @@ -26,8 +36,10 @@ function TaxExemptionsTab() { const { accountId } = useParams({ from: '/_authenticated/accounts/$accountId/tax-exemptions' }) const queryClient = useQueryClient() const [dialogOpen, setDialogOpen] = useState(false) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') - const { data, isLoading } = useQuery(taxExemptionListOptions(accountId)) + const { data, isLoading } = useQuery(taxExemptionListOptions(accountId, params)) const createMutation = useMutation({ mutationFn: (data: Record) => taxExemptionMutations.create(accountId, data), @@ -61,7 +73,59 @@ function TaxExemptionsTab() { onError: (err) => toast.error(err.message), }) - const exemptions = data?.data ?? [] + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + const columns: Column[] = [ + { + key: 'certificate_number', + header: 'Certificate #', + sortable: true, + render: (e) => {e.certificateNumber}, + }, + { + key: 'type', + header: 'Type', + render: (e) => <>{e.certificateType ?? '-'}, + }, + { + key: 'state', + header: 'State', + render: (e) => <>{e.issuingState ?? '-'}, + }, + { + key: 'expires_at', + header: 'Expires', + sortable: true, + render: (e) => <>{e.expiresAt ?? '-'}, + }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (e) => statusBadge(e.status), + }, + { + key: 'actions', + header: 'Actions', + render: (e) => ( +
+ {e.status === 'pending' && ( + + )} + {e.status === 'approved' && ( + + )} +
+ ), + }, + ] return (
@@ -78,51 +142,31 @@ function TaxExemptionsTab() {
- {isLoading ? ( -

Loading...

- ) : exemptions.length === 0 ? ( -

No tax exemptions

- ) : ( -
- - - - Certificate # - Type - State - Expires - Status - Actions - - - - {exemptions.map((e) => ( - - {e.certificateNumber} - {e.certificateType ?? '-'} - {e.issuingState ?? '-'} - {e.expiresAt ?? '-'} - {statusBadge(e.status)} - -
- {e.status === 'pending' && ( - - )} - {e.status === 'approved' && ( - - )} -
-
-
- ))} -
-
+
+
+ + setSearchInput(e.target.value)} + className="pl-9" + />
- )} + +
+ +
) } diff --git a/packages/admin/src/routes/_authenticated/accounts/index.tsx b/packages/admin/src/routes/_authenticated/accounts/index.tsx index 9978238..8aa08a4 100644 --- a/packages/admin/src/routes/_authenticated/accounts/index.tsx +++ b/packages/admin/src/routes/_authenticated/accounts/index.tsx @@ -8,6 +8,7 @@ import { accountColumns } from '@/components/accounts/account-table' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Plus, Search } from 'lucide-react' +import { useAuthStore } from '@/stores/auth.store' import type { Account } from '@/types/account' export const Route = createFileRoute('/_authenticated/accounts/')({ @@ -23,6 +24,7 @@ export const Route = createFileRoute('/_authenticated/accounts/')({ function AccountsListPage() { const navigate = useNavigate() + const hasPermission = useAuthStore((s) => s.hasPermission) const { params, setPage, setSearch, setSort } = usePagination() const [searchInput, setSearchInput] = useState(params.q ?? '') @@ -41,10 +43,12 @@ function AccountsListPage() {

Accounts

- + {hasPermission('accounts.edit') && ( + + )}
diff --git a/packages/admin/src/routes/_authenticated/index.tsx b/packages/admin/src/routes/_authenticated/index.tsx index 6e11299..6e054a3 100644 --- a/packages/admin/src/routes/_authenticated/index.tsx +++ b/packages/admin/src/routes/_authenticated/index.tsx @@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/')({ beforeLoad: () => { - throw redirect({ to: '/accounts' }) + throw redirect({ to: '/accounts', search: {} as any }) }, }) diff --git a/packages/admin/src/routes/_authenticated/members/$memberId.tsx b/packages/admin/src/routes/_authenticated/members/$memberId.tsx index 1d05d08..408da0d 100644 --- a/packages/admin/src/routes/_authenticated/members/$memberId.tsx +++ b/packages/admin/src/routes/_authenticated/members/$memberId.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api-client' import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers' import { MemberForm } from '@/components/accounts/member-form' -import { IdentifierForm } from '@/components/accounts/identifier-form' +import { IdentifierForm, type IdentifierFiles } from '@/components/accounts/identifier-form' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -12,6 +12,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Skeleton } from '@/components/ui/skeleton' import { ArrowLeft, Plus, Trash2, CreditCard } from 'lucide-react' import { toast } from 'sonner' +import { AvatarUpload } from '@/components/shared/avatar-upload' +import { useAuthStore } from '@/stores/auth.store' import type { Member, MemberIdentifier } from '@/types/account' import { useState } from 'react' import { queryOptions } from '@tanstack/react-query' @@ -27,6 +29,29 @@ export const Route = createFileRoute('/_authenticated/members/$memberId')({ component: MemberDetailPage, }) +function IdentifierImages({ identifierId }: { identifierId: string }) { + const { data } = useQuery({ + queryKey: ['files', 'member_identifier', identifierId], + queryFn: () => api.get<{ data: { id: string; path: string; category: string }[] }>('/v1/files', { + entityType: 'member_identifier', + entityId: identifierId, + }), + }) + + const files = data?.data ?? [] + const frontFile = files.find((f) => f.category === 'front') + const backFile = files.find((f) => f.category === 'back') + + if (!frontFile && !backFile) return null + + return ( +
+ {frontFile && Front} + {backFile && Back} +
+ ) +} + const ID_TYPE_LABELS: Record = { drivers_license: "Driver's License", passport: 'Passport', @@ -39,8 +64,10 @@ function MemberDetailPage() { const queryClient = useQueryClient() const [addIdOpen, setAddIdOpen] = useState(false) + const token = useAuthStore((s) => s.token) const { data: member, isLoading } = useQuery(memberDetailOptions(memberId)) const { data: idsData } = useQuery(identifierListOptions(memberId)) + const [createLoading, setCreateLoading] = useState(false) const updateMutation = useMutation({ mutationFn: (data: Record) => api.patch(`/v1/members/${memberId}`, data), @@ -51,15 +78,52 @@ function MemberDetailPage() { onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'), }) - const createIdMutation = useMutation({ - mutationFn: (data: Record) => identifierMutations.create(memberId, data), - onSuccess: () => { + async function uploadIdFile(identifierId: string, file: File, category: string): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('entityType', 'member_identifier') + formData.append('entityId', identifierId) + formData.append('category', category) + + const res = await fetch('/v1/files', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }) + if (!res.ok) return null + const data = await res.json() + return data.id + } + + async function handleCreateIdentifier(data: Record, files: IdentifierFiles) { + setCreateLoading(true) + try { + const identifier = await identifierMutations.create(memberId, data) + + // Upload images and update identifier with file IDs + const updates: Record = {} + if (files.front) { + const fileId = await uploadIdFile(identifier.id, files.front, 'front') + if (fileId) updates.imageFrontFileId = fileId + } + if (files.back) { + const fileId = await uploadIdFile(identifier.id, files.back, 'back') + if (fileId) updates.imageBackFileId = fileId + } + + if (Object.keys(updates).length > 0) { + await identifierMutations.update(identifier.id, updates) + } + queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) }) toast.success('ID added') setAddIdOpen(false) - }, - onError: (err) => toast.error(err.message), - }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to add ID') + } finally { + setCreateLoading(false) + } + } const deleteIdMutation = useMutation({ mutationFn: identifierMutations.delete, @@ -88,7 +152,7 @@ function MemberDetailPage() { return (
-
@@ -111,7 +175,14 @@ function MemberDetailPage() { Details - + +
+ +
+

{member.firstName} {member.lastName}

+

{member.email ?? 'No email'}

+
+
Add Identity Document - + @@ -154,11 +225,8 @@ function MemberDetailPage() { {id.issuedDate && Issued: {id.issuedDate}} {id.expiresAt && Expires: {id.expiresAt}}
- {(id.imageFront || id.imageBack) && ( -
- {id.imageFront && Front} - {id.imageBack && Back} -
+ {(id.imageFrontFileId || id.imageBackFileId) && ( + )}
diff --git a/packages/admin/src/routes/_authenticated/members/index.tsx b/packages/admin/src/routes/_authenticated/members/index.tsx index 5119427..f05dea4 100644 --- a/packages/admin/src/routes/_authenticated/members/index.tsx +++ b/packages/admin/src/routes/_authenticated/members/index.tsx @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Search, Plus, MoreVertical, Pencil, Users } from 'lucide-react' +import { useAuthStore } from '@/stores/auth.store' export const Route = createFileRoute('/_authenticated/members/')({ validateSearch: (search: Record) => ({ @@ -28,6 +29,7 @@ export const Route = createFileRoute('/_authenticated/members/')({ function MembersListPage() { const navigate = useNavigate() + const hasPermission = useAuthStore((s) => s.hasPermission) const { params, setPage, setSearch, setSort } = usePagination() const [searchInput, setSearchInput] = useState(params.q ?? '') @@ -100,10 +102,12 @@ function MembersListPage() {

Members

- + {hasPermission('accounts.edit') && ( + + )}
diff --git a/packages/admin/src/routes/_authenticated/profile.tsx b/packages/admin/src/routes/_authenticated/profile.tsx index c096283..61abed0 100644 --- a/packages/admin/src/routes/_authenticated/profile.tsx +++ b/packages/admin/src/routes/_authenticated/profile.tsx @@ -13,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { Sun, Moon, Monitor } from 'lucide-react' import { toast } from 'sonner' +import { AvatarUpload } from '@/components/shared/avatar-upload' export const Route = createFileRoute('/_authenticated/profile')({ component: ProfilePage, @@ -92,6 +93,15 @@ function ProfilePage() { Account + {profile?.id && ( +
+ +
+

{profile.firstName} {profile.lastName}

+

{profile.email}

+
+
+ )}
diff --git a/packages/admin/src/routes/_authenticated/roles/$roleId.tsx b/packages/admin/src/routes/_authenticated/roles/$roleId.tsx index eb60e80..31ec29c 100644 --- a/packages/admin/src/routes/_authenticated/roles/$roleId.tsx +++ b/packages/admin/src/routes/_authenticated/roles/$roleId.tsx @@ -100,7 +100,7 @@ function RoleDetailPage() { return (
-
@@ -177,7 +177,7 @@ function RoleDetailPage() { -
diff --git a/packages/admin/src/routes/_authenticated/roles/index.tsx b/packages/admin/src/routes/_authenticated/roles/index.tsx index e08551a..7da6c47 100644 --- a/packages/admin/src/routes/_authenticated/roles/index.tsx +++ b/packages/admin/src/routes/_authenticated/roles/index.tsx @@ -1,9 +1,12 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { roleListOptions, rbacKeys, rbacMutations } from '@/api/rbac' +import { useState } from 'react' +import { rolePageOptions, rbacKeys, rbacMutations } from '@/api/rbac' +import { usePagination } from '@/hooks/use-pagination' +import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuContent, @@ -11,99 +14,142 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { Plus, MoreVertical, Pencil, Trash2, Shield } from 'lucide-react' +import { Plus, MoreVertical, Pencil, Trash2, Shield, Search } from 'lucide-react' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth.store' +import type { Role } from '@/types/rbac' export const Route = createFileRoute('/_authenticated/roles/')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 25, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), component: RolesListPage, }) function RolesListPage() { const navigate = useNavigate() const queryClient = useQueryClient() - const { data, isLoading } = useQuery(roleListOptions()) + const hasPermission = useAuthStore((s) => s.hasPermission) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') + + const { data, isLoading } = useQuery(rolePageOptions(params)) const deleteMutation = useMutation({ mutationFn: rbacMutations.deleteRole, onSuccess: () => { queryClient.invalidateQueries({ queryKey: rbacKeys.roles }) + queryClient.invalidateQueries({ queryKey: ['roles', 'list'] }) toast.success('Role deleted') }, onError: (err) => toast.error(err.message), }) - const roles = data?.data ?? [] + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + const roleColumns: Column[] = [ + { + key: 'name', + header: 'Name', + sortable: true, + render: (row) => ( +
+ + {row.name} +
+ ), + }, + { + key: 'slug', + header: 'Slug', + sortable: true, + render: (row) => {row.slug}, + }, + { + key: 'description', + header: 'Description', + render: (row) => {row.description ?? '-'}, + }, + { + key: 'type', + header: 'Type', + render: (row) => + row.isSystem ? System : Custom, + }, + { + key: 'actions', + header: '', + render: (row) => ( + + + + + + navigate({ to: '/roles/$roleId', params: { roleId: row.id } })}> + + Edit Permissions + + {!row.isSystem && hasPermission('users.admin') && ( + <> + + deleteMutation.mutate(row.id)}> + + Delete + + + )} + + + ), + }, + ] return (

Roles

- + {hasPermission('users.admin') && ( + + )}
- {isLoading ? ( -

Loading...

- ) : roles.length === 0 ? ( -

No roles found

- ) : ( -
- - - - Name - Slug - Description - Type - - - - - {roles.map((role) => ( - - -
- - {role.name} -
-
- {role.slug} - {role.description ?? '-'} - - {role.isSystem ? System : Custom} - - - - - - - - navigate({ to: '/roles/$roleId', params: { roleId: role.id } })}> - - Edit Permissions - - {!role.isSystem && ( - <> - - deleteMutation.mutate(role.id)}> - - Delete - - - )} - - - -
- ))} -
-
+ +
+ + setSearchInput(e.target.value)} + className="pl-9" + />
- )} + + + +
) } diff --git a/packages/admin/src/routes/_authenticated/roles/new.tsx b/packages/admin/src/routes/_authenticated/roles/new.tsx index 1336f96..7ad7be2 100644 --- a/packages/admin/src/routes/_authenticated/roles/new.tsx +++ b/packages/admin/src/routes/_authenticated/roles/new.tsx @@ -29,7 +29,7 @@ function NewRolePage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: rbacKeys.roles }) toast.success('Role created') - navigate({ to: '/roles' }) + navigate({ to: '/roles', search: {} as any }) }, onError: (err) => toast.error(err.message), }) @@ -153,7 +153,7 @@ function NewRolePage() { -
diff --git a/packages/admin/src/routes/_authenticated/users.tsx b/packages/admin/src/routes/_authenticated/users.tsx index c270c21..6b2c698 100644 --- a/packages/admin/src/routes/_authenticated/users.tsx +++ b/packages/admin/src/routes/_authenticated/users.tsx @@ -1,12 +1,14 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { api } from '@/lib/api-client' -import { roleListOptions, rbacKeys, rbacMutations } from '@/api/rbac' -import { userRolesOptions, type UserRecord } from '@/api/users' +import { userListOptions, userRolesOptions, userKeys, userMutations, type UserRecord } from '@/api/users' +import { roleListOptions, rbacMutations } from '@/api/rbac' +import { usePagination } from '@/hooks/use-pagination' +import { DataTable, type Column } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { @@ -15,38 +17,21 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { MoreVertical, Shield, Plus, X, KeyRound } from 'lucide-react' +import { MoreVertical, Shield, Plus, X, KeyRound, Search, UserCheck, UserX } from 'lucide-react' import { toast } from 'sonner' -import { queryOptions } from '@tanstack/react-query' - -function userListOptions() { - return queryOptions({ - queryKey: ['users'], - queryFn: () => api.get<{ data: UserRecord[] }>('/v1/users'), - }) -} +import { useAuthStore } from '@/stores/auth.store' export const Route = createFileRoute('/_authenticated/users')({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + limit: Number(search.limit) || 25, + q: (search.q as string) || undefined, + sort: (search.sort as string) || undefined, + order: (search.order as 'asc' | 'desc') || 'asc', + }), component: UsersPage, }) -function UserRoleBadges({ userId }: { userId: string }) { - const { data } = useQuery(userRolesOptions(userId)) - const roles = data?.data ?? [] - - if (roles.length === 0) return No roles - - return ( -
- {roles.map((r) => ( - - {r.name} - - ))} -
- ) -} - function ManageRolesDialog({ user, open, onClose }: { user: UserRecord; open: boolean; onClose: () => void }) { const queryClient = useQueryClient() const { data: userRolesData } = useQuery(userRolesOptions(user.id)) @@ -61,7 +46,7 @@ function ManageRolesDialog({ user, open, onClose }: { user: UserRecord; open: bo const assignMutation = useMutation({ mutationFn: (roleId: string) => rbacMutations.assignRole(user.id, roleId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users', user.id, 'roles'] }) + queryClient.invalidateQueries({ queryKey: ['users'] }) setSelectedRoleId('') toast.success('Role assigned') }, @@ -71,7 +56,7 @@ function ManageRolesDialog({ user, open, onClose }: { user: UserRecord; open: bo const removeMutation = useMutation({ mutationFn: (roleId: string) => rbacMutations.removeRole(user.id, roleId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users', user.id, 'roles'] }) + queryClient.invalidateQueries({ queryKey: ['users'] }) toast.success('Role removed') }, onError: (err) => toast.error(err.message), @@ -135,10 +120,131 @@ function ManageRolesDialog({ user, open, onClose }: { user: UserRecord; open: bo } function UsersPage() { - const { data, isLoading } = useQuery(userListOptions()) + const queryClient = useQueryClient() + const hasPermission = useAuthStore((s) => s.hasPermission) + const { params, setPage, setSearch, setSort } = usePagination() + const [searchInput, setSearchInput] = useState(params.q ?? '') const [managingUser, setManagingUser] = useState(null) - const allUsers = data?.data ?? [] + const { data, isLoading } = useQuery(userListOptions(params)) + + const statusMutation = useMutation({ + mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) => + userMutations.toggleStatus(userId, isActive), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + toast.success(vars.isActive ? 'User enabled' : 'User disabled') + }, + onError: (err) => toast.error(err.message), + }) + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + setSearch(searchInput) + } + + const userColumns: Column[] = [ + { + key: 'name', + header: 'Name', + sortable: true, + render: (row) => ( + + {row.firstName} {row.lastName} + + ), + }, + { + key: 'email', + header: 'Email', + sortable: true, + render: (row) => {row.email}, + }, + { + key: 'roles', + header: 'Roles', + render: (row) => + row.roles.length === 0 ? ( + No roles + ) : ( +
+ {row.roles.map((r) => ( + + {r.name} + + ))} +
+ ), + }, + { + key: 'status', + header: 'Status', + render: (row) => + row.isActive ? ( + Active + ) : ( + Disabled + ), + }, + { + key: 'created_at', + header: 'Created', + sortable: true, + render: (row) => new Date(row.createdAt).toLocaleDateString(), + }, + { + key: 'actions', + header: '', + render: (row) => ( + + + + + + {hasPermission('users.edit') && ( + setManagingUser(row)}> + + Manage Roles + + )} + {hasPermission('users.admin') && ( + { + try { + const res = await api.post<{ resetLink: string }>(`/v1/auth/reset-password/${row.id}`, {}) + await navigator.clipboard.writeText(res.resetLink) + toast.success('Reset link copied to clipboard') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to generate link') + } + }}> + + Reset Password Link + + )} + {hasPermission('users.admin') && ( + statusMutation.mutate({ userId: row.id, isActive: !row.isActive })} + > + {row.isActive ? ( + <> + + Disable User + + ) : ( + <> + + Enable User + + )} + + )} + + + ), + }, + ] return (
@@ -148,60 +254,31 @@ function UsersPage() { setManagingUser(null)} /> )} - {isLoading ? ( -

Loading...

- ) : allUsers.length === 0 ? ( -

No users found

- ) : ( -
- - - - Name - Email - Roles - - - - - {allUsers.map((user) => ( - - {user.firstName} {user.lastName} - {user.email} - - - - - - - - setManagingUser(user)}> - - Manage Roles - - { - try { - const res = await api.post<{ resetLink: string }>(`/v1/auth/reset-password/${user.id}`, {}) - await navigator.clipboard.writeText(res.resetLink) - toast.success('Reset link copied to clipboard') - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to generate link') - } - }}> - - Reset Password Link - - - - - - ))} - -
+
+
+ + setSearchInput(e.target.value)} + className="pl-9" + />
- )} + +
+ +
) } diff --git a/packages/admin/src/routes/login.tsx b/packages/admin/src/routes/login.tsx index 62cee03..72e42b2 100644 --- a/packages/admin/src/routes/login.tsx +++ b/packages/admin/src/routes/login.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute('/login')({ beforeLoad: () => { const { token } = useAuthStore.getState() if (token) { - throw redirect({ to: '/accounts' }) + throw redirect({ to: '/accounts', search: {} as any }) } }, component: LoginPage, @@ -30,7 +30,7 @@ function LoginPage() { const res = await login(email, password) setAuth(res.token, res.user) await router.invalidate() - await router.navigate({ to: '/accounts', replace: true }) + await router.navigate({ to: '/accounts', search: {} as any, replace: true }) } catch (err) { setError(err instanceof Error ? err.message : 'Login failed') } finally { diff --git a/packages/admin/src/stores/auth.store.ts b/packages/admin/src/stores/auth.store.ts index 424b3c8..911aa52 100644 --- a/packages/admin/src/stores/auth.store.ts +++ b/packages/admin/src/stores/auth.store.ts @@ -12,16 +12,49 @@ interface AuthState { token: string | null user: User | null companyId: string | null + permissions: Set + permissionsLoaded: boolean setAuth: (token: string, user: User) => void + setPermissions: (slugs: string[]) => void + hasPermission: (slug: string) => boolean logout: () => void } +/** + * Permission inheritance: admin implies edit implies view for the same domain. + * Must match the backend logic in plugins/auth.ts. + */ +const ACTION_HIERARCHY: Record = { + admin: ['admin', 'edit', 'view'], + edit: ['edit', 'view'], + view: ['view'], + upload: ['upload'], + delete: ['delete'], + send: ['send'], + export: ['export'], +} + +function expandPermissions(slugs: string[]): Set { + const expanded = new Set() + for (const slug of slugs) { + expanded.add(slug) + const [domain, action] = slug.split('.') + const implied = ACTION_HIERARCHY[action] + if (implied && domain) { + for (const a of implied) { + expanded.add(`${domain}.${a}`) + } + } + } + return expanded +} + function decodeJwtPayload(token: string): { id: string; companyId: string; role: string } { const payload = token.split('.')[1] return JSON.parse(atob(payload)) } -function loadSession(): { token: string; user: User; companyId: string } | null { +function loadSession(): { token: string; user: User; companyId: string; permissions?: string[] } | null { try { const raw = sessionStorage.getItem('forte-auth') if (!raw) return null @@ -31,20 +64,23 @@ function loadSession(): { token: string; user: User; companyId: string } | null } } -function saveSession(token: string, user: User, companyId: string) { - sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId })) +function saveSession(token: string, user: User, companyId: string, permissions?: string[]) { + sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId, permissions })) } function clearSession() { sessionStorage.removeItem('forte-auth') } -export const useAuthStore = create((set) => { +export const useAuthStore = create((set, get) => { const initial = typeof window !== 'undefined' ? loadSession() : null + const initialPerms = initial?.permissions ? expandPermissions(initial.permissions) : new Set() return { token: initial?.token ?? null, user: initial?.user ?? null, companyId: initial?.companyId ?? null, + permissions: initialPerms, + permissionsLoaded: initialPerms.size > 0, setAuth: (token, user) => { const payload = decodeJwtPayload(token) @@ -52,8 +88,22 @@ export const useAuthStore = create((set) => { set({ token, user, companyId: payload.companyId }) }, + setPermissions: (slugs: string[]) => { + const expanded = expandPermissions(slugs) + // Update session storage to include permissions + const { token, user, companyId } = get() + if (token && user && companyId) { + saveSession(token, user, companyId, slugs) + } + set({ permissions: expanded, permissionsLoaded: true }) + }, + + hasPermission: (slug: string) => { + return get().permissions.has(slug) + }, + logout: () => { clearSession() - set({ token: null, user: null, companyId: null }) + set({ token: null, user: null, companyId: null, permissions: new Set(), permissionsLoaded: false }) }, }}) diff --git a/packages/admin/src/types/account.ts b/packages/admin/src/types/account.ts index dec5e1e..b1f8562 100644 --- a/packages/admin/src/types/account.ts +++ b/packages/admin/src/types/account.ts @@ -74,8 +74,8 @@ export interface MemberIdentifier { issuingAuthority: string | null issuedDate: string | null expiresAt: string | null - imageFront: string | null - imageBack: string | null + imageFrontFileId: string | null + imageBackFileId: string | null notes: string | null isPrimary: boolean createdAt: string diff --git a/packages/backend/api-tests/suites/files.ts b/packages/backend/api-tests/suites/files.ts index c4032cb..f58c000 100644 --- a/packages/backend/api-tests/suites/files.ts +++ b/packages/backend/api-tests/suites/files.ts @@ -139,6 +139,35 @@ suite('Files', { tags: ['files', 'storage'] }, (t) => { t.assert.equal(res.status, 400) }) + t.test('uploads profile picture for user entity type', { tags: ['upload', 'profile'] }, async () => { + // Get the current test user ID from the users list + const usersRes = await t.api.get('/v1/users') + const testUser = usersRes.data.data[0] + t.assert.ok(testUser) + + const formData = new FormData() + formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'avatar.jpg') + formData.append('entityType', 'user') + formData.append('entityId', testUser.id) + formData.append('category', 'profile') + + const res = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + const data = await res.json() + + t.assert.equal(res.status, 201) + t.assert.equal(data.entityType, 'user') + t.assert.equal(data.category, 'profile') + + // Verify it shows up in files list + const listRes = await t.api.get('/v1/files', { entityType: 'user', entityId: testUser.id }) + t.assert.status(listRes, 200) + t.assert.greaterThan(listRes.data.data.length, 0) + }) + t.test('returns 404 for missing file', { tags: ['read'] }, async () => { const res = await t.api.get('/v1/files/a0000000-0000-0000-0000-999999999999') t.assert.status(res, 404) diff --git a/packages/backend/api-tests/suites/rbac.ts b/packages/backend/api-tests/suites/rbac.ts index 9db3b6e..62e04ee 100644 --- a/packages/backend/api-tests/suites/rbac.ts +++ b/packages/backend/api-tests/suites/rbac.ts @@ -172,6 +172,7 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => { t.test('roles list returns system roles', { tags: ['roles'] }, async () => { const res = await t.api.get('/v1/roles') t.assert.status(res, 200) + t.assert.ok(res.data.pagination) const slugs = res.data.data.map((r: { slug: string }) => r.slug) t.assert.includes(slugs, 'admin') t.assert.includes(slugs, 'manager') @@ -181,6 +182,13 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => { t.assert.includes(slugs, 'viewer') }) + t.test('roles/all returns unpaginated list', { tags: ['roles'] }, async () => { + const res = await t.api.get('/v1/roles/all') + t.assert.status(res, 200) + t.assert.greaterThan(res.data.data.length, 5) + t.assert.equal(res.data.pagination, undefined) + }) + t.test('permissions list returns all system permissions', { tags: ['permissions'] }, async () => { const res = await t.api.get('/v1/permissions') t.assert.status(res, 200) @@ -196,6 +204,120 @@ suite('RBAC', { tags: ['rbac', 'permissions'] }, (t) => { t.assert.equal(deleteRes.status, 403) }) + t.test('roles search by name', { tags: ['roles', 'search'] }, async () => { + const res = await t.api.get('/v1/roles', { q: 'admin' }) + t.assert.status(res, 200) + t.assert.greaterThan(res.data.data.length, 0) + t.assert.ok(res.data.data.every((r: { name: string }) => r.name.toLowerCase().includes('admin'))) + }) + + t.test('roles sort by name descending', { tags: ['roles', 'sort'] }, async () => { + const res = await t.api.get('/v1/roles', { sort: 'name', order: 'desc' }) + t.assert.status(res, 200) + const names = res.data.data.map((r: { name: string }) => r.name) + const sorted = [...names].sort().reverse() + t.assert.equal(JSON.stringify(names), JSON.stringify(sorted)) + }) + + t.test('users list is paginated with roles', { tags: ['users', 'pagination'] }, async () => { + const res = await t.api.get('/v1/users') + t.assert.status(res, 200) + t.assert.ok(res.data.pagination) + t.assert.greaterThan(res.data.data.length, 0) + // Each user should have a roles array + const first = res.data.data[0] + t.assert.ok(Array.isArray(first.roles)) + }) + + t.test('users search by name', { tags: ['users', 'search'] }, async () => { + // Create a user with a distinctive name + await fetch(`${t.baseUrl}/v1/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, + body: JSON.stringify({ email: `searchme-${Date.now()}@test.com`, password: 'testpassword1234', firstName: 'Searchable', lastName: 'Pessoa', role: 'staff' }), + }) + + const res = await t.api.get('/v1/users', { q: 'Searchable' }) + t.assert.status(res, 200) + t.assert.equal(res.data.data.length, 1) + t.assert.equal(res.data.data[0].firstName, 'Searchable') + }) + + t.test('users sort by email ascending', { tags: ['users', 'sort'] }, async () => { + const res = await t.api.get('/v1/users', { sort: 'email', order: 'asc' }) + t.assert.status(res, 200) + const emails = res.data.data.map((u: { email: string }) => u.email) + const sorted = [...emails].sort() + t.assert.equal(JSON.stringify(emails), JSON.stringify(sorted)) + }) + + t.test('can disable and re-enable a user', { tags: ['users', 'status'] }, async () => { + // Create a user + const email = `disable-${Date.now()}@test.com` + const password = 'testpassword1234' + const regRes = await fetch(`${t.baseUrl}/v1/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-company-id': 'a0000000-0000-0000-0000-000000000001' }, + body: JSON.stringify({ email, password, firstName: 'Disable', lastName: 'Me', role: 'staff' }), + }) + const regData = await regRes.json() as { user: { id: string } } + const userId = regData.user.id + + // Disable the user + const disableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: false }) + t.assert.status(disableRes, 200) + t.assert.equal(disableRes.data.isActive, false) + + // Disabled user cannot authenticate + const loginRes = await fetch(`${t.baseUrl}/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const loginData = await loginRes.json() as { token: string } + + // Try to use the token — should get 401 + const authRes = await fetch(`${t.baseUrl}/v1/accounts`, { + headers: { Authorization: `Bearer ${loginData.token}` }, + }) + t.assert.equal(authRes.status, 401) + + // Re-enable the user + const enableRes = await t.api.patch(`/v1/users/${userId}/status`, { isActive: true }) + t.assert.status(enableRes, 200) + t.assert.equal(enableRes.data.isActive, true) + + // Now they can authenticate again + const reLoginRes = await fetch(`${t.baseUrl}/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const reLoginData = await reLoginRes.json() as { token: string } + const reAuthRes = await fetch(`${t.baseUrl}/v1/accounts`, { + headers: { Authorization: `Bearer ${reLoginData.token}` }, + }) + // Will be 403 (no permissions) but NOT 401 (not disabled) + t.assert.notEqual(reAuthRes.status, 401) + }) + + t.test('cannot disable yourself', { tags: ['users', 'status'] }, async () => { + // Get current user ID from the users list + const usersRes = await t.api.get('/v1/users') + const currentUser = usersRes.data.data.find((u: { email: string }) => u.email === 'test@forte.dev') + t.assert.ok(currentUser) + + const res = await t.api.patch(`/v1/users/${currentUser.id}/status`, { isActive: false }) + t.assert.equal(res.status, 400) + }) + + t.test('users list includes isActive field', { tags: ['users'] }, async () => { + const res = await t.api.get('/v1/users') + t.assert.status(res, 200) + const first = res.data.data[0] + t.assert.equal(typeof first.isActive, 'boolean') + }) + t.test('can create and delete custom role', { tags: ['roles'] }, async () => { const createRes = await t.api.post('/v1/roles', { name: 'Temp Role', diff --git a/packages/backend/src/db/migrations/0014_user_is_active.sql b/packages/backend/src/db/migrations/0014_user_is_active.sql new file mode 100644 index 0000000..8c9ac6c --- /dev/null +++ b/packages/backend/src/db/migrations/0014_user_is_active.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "is_active" boolean NOT NULL DEFAULT true; \ No newline at end of file diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 57371b8..c4617e1 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1774730000000, "tag": "0013_rbac", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1774740000000, + "tag": "0014_user_is_active", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/users.ts b/packages/backend/src/db/schema/users.ts index 418a5f2..c988047 100644 --- a/packages/backend/src/db/schema/users.ts +++ b/packages/backend/src/db/schema/users.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core' +import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex, boolean } from 'drizzle-orm/pg-core' import { companies } from './stores.js' export const userRoleEnum = pgEnum('user_role', [ @@ -19,6 +19,7 @@ export const users = pgTable('user', { firstName: varchar('first_name', { length: 100 }).notNull(), lastName: varchar('last_name', { length: 100 }).notNull(), role: userRoleEnum('role').notNull().default('staff'), + isActive: boolean('is_active').notNull().default(true), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 74dee5f..d18a207 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -1,5 +1,7 @@ import fp from 'fastify-plugin' import fjwt from '@fastify/jwt' +import { eq } from 'drizzle-orm' +import { users } from '../db/schema/users.js' import { RbacService } from '../services/rbac.service.js' declare module 'fastify' { @@ -72,6 +74,18 @@ export const authPlugin = fp(async (app) => { await request.jwtVerify() request.companyId = request.user.companyId + // Check if user account is active + const [dbUser] = await app.db + .select({ isActive: users.isActive }) + .from(users) + .where(eq(users.id, request.user.id)) + .limit(1) + + if (!dbUser || !dbUser.isActive) { + reply.status(401).send({ error: { message: 'Account disabled', statusCode: 401 } }) + return + } + // Load permissions from DB and expand with inheritance const permSlugs = await RbacService.getUserPermissions(app.db, request.user.id) request.permissions = expandPermissions(permSlugs) diff --git a/packages/backend/src/plugins/error-handler.ts b/packages/backend/src/plugins/error-handler.ts index 6d86cc5..13be1c7 100644 --- a/packages/backend/src/plugins/error-handler.ts +++ b/packages/backend/src/plugins/error-handler.ts @@ -2,7 +2,7 @@ import fp from 'fastify-plugin' import { AppError, ValidationError } from '../lib/errors.js' export const errorHandlerPlugin = fp(async (app) => { - app.setErrorHandler((error, request, reply) => { + app.setErrorHandler((error: Error & { statusCode?: number; stack?: string }, request, reply) => { // Use AppError statusCode if available, else Fastify's, else 500 const statusCode = error instanceof AppError ? error.statusCode diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts index e49a848..63b9b77 100644 --- a/packages/backend/src/routes/v1/accounts.ts +++ b/packages/backend/src/routes/v1/accounts.ts @@ -124,6 +124,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) const account = await AccountService.create(app.db, request.companyId, { name: `${member.firstName} ${member.lastName}`, + billingMode: 'consolidated', }) targetAccountId = account.id } @@ -148,8 +149,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { const { memberId } = request.params as { memberId: string } - const identifiers = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId) - return reply.send({ data: identifiers }) + const params = PaginationSchema.parse(request.query) + const result = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId, params) + return reply.send(result) }) app.patch('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => { @@ -191,8 +193,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { const { accountId } = request.params as { accountId: string } - const links = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId) - return reply.send({ data: links }) + const params = PaginationSchema.parse(request.query) + const result = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId, params) + return reply.send(result) }) app.patch('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => { @@ -227,8 +230,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { const { accountId } = request.params as { accountId: string } - const methods = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId) - return reply.send({ data: methods }) + const params = PaginationSchema.parse(request.query) + const result = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId, params) + return reply.send(result) }) app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { @@ -270,8 +274,9 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { const { accountId } = request.params as { accountId: string } - const exemptions = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId) - return reply.send({ data: exemptions }) + const params = PaginationSchema.parse(request.query) + const result = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId, params) + return reply.send(result) }) app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => { diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index a97f8f1..169215a 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -176,7 +176,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) // Generate a signed reset token that expires in 1 hour - const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '1h' }) + const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' }) const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}` request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated') diff --git a/packages/backend/src/routes/v1/files.ts b/packages/backend/src/routes/v1/files.ts index 389163c..f3c6d49 100644 --- a/packages/backend/src/routes/v1/files.ts +++ b/packages/backend/src/routes/v1/files.ts @@ -42,7 +42,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => { } // Validate entityType is a known type - const allowedEntityTypes = ['member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket'] + const allowedEntityTypes = ['user', 'member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket'] if (!allowedEntityTypes.includes(entityType)) { throw new ValidationError(`Invalid entityType: ${entityType}`) } diff --git a/packages/backend/src/routes/v1/rbac.ts b/packages/backend/src/routes/v1/rbac.ts index 6208bfd..7462e12 100644 --- a/packages/backend/src/routes/v1/rbac.ts +++ b/packages/backend/src/routes/v1/rbac.ts @@ -1,28 +1,110 @@ import type { FastifyPluginAsync } from 'fastify' -import { eq, and } from 'drizzle-orm' +import { eq, and, count, sql, type Column } from 'drizzle-orm' +import { PaginationSchema } from '@forte/shared/schemas' import { RbacService } from '../../services/rbac.service.js' import { ValidationError } from '../../lib/errors.js' import { users } from '../../db/schema/users.js' +import { roles, userRoles } from '../../db/schema/rbac.js' +import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../../utils/pagination.js' export const rbacRoutes: FastifyPluginAsync = async (app) => { // --- Users list --- app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { - const allUsers = await app.db + const params = PaginationSchema.parse(request.query) + const baseWhere = eq(users.companyId, request.companyId) + + const searchCondition = params.q + ? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email]) + : undefined + + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + name: users.lastName, + email: users.email, + created_at: users.createdAt, + } + + let query = app.db .select({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, + isActive: users.isActive, createdAt: users.createdAt, }) .from(users) - .where(eq(users.companyId, request.companyId)) - .orderBy(users.lastName) + .where(where) + .$dynamic() - return reply.send({ data: allUsers }) + query = withSort(query, params.sort, params.order, sortableColumns, users.lastName) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + app.db.select({ total: count() }).from(users).where(where), + ]) + + // Attach roles to each user + const userIds = data.map((u) => u.id) + const roleAssignments = userIds.length > 0 + ? await app.db + .select({ + userId: userRoles.userId, + roleId: roles.id, + roleName: roles.name, + roleSlug: roles.slug, + isSystem: roles.isSystem, + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(sql`${userRoles.userId} IN ${userIds}`) + : [] + + const rolesByUser = new Map() + for (const ra of roleAssignments) { + const list = rolesByUser.get(ra.userId) ?? [] + list.push({ id: ra.roleId, name: ra.roleName, slug: ra.roleSlug, isSystem: ra.isSystem }) + rolesByUser.set(ra.userId, list) + } + + const usersWithRoles = data.map((u) => ({ + ...u, + roles: rolesByUser.get(u.id) ?? [], + })) + + return reply.send(paginatedResponse(usersWithRoles, total, params.page, params.limit)) }) + // --- User status --- + + app.patch('/users/:userId/status', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { + const { userId } = request.params as { userId: string } + const { isActive } = request.body as { isActive?: boolean } + + if (typeof isActive !== 'boolean') { + throw new ValidationError('isActive (boolean) is required') + } + + // Prevent disabling yourself + if (userId === request.user.id) { + throw new ValidationError('Cannot change your own account status') + } + + const [updated] = await app.db + .update(users) + .set({ isActive, updatedAt: new Date() }) + .where(and(eq(users.id, userId), eq(users.companyId, request.companyId))) + .returning({ id: users.id, isActive: users.isActive }) + + if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) + + request.log.info({ userId, isActive, changedBy: request.user.id }, 'User status changed') + return reply.send(updated) + }) + // --- Permissions --- app.get('/permissions', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { @@ -33,7 +115,18 @@ export const rbacRoutes: FastifyPluginAsync = async (app) => { // --- Roles --- app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { - const data = await RbacService.listRoles(app.db, request.companyId) + const params = PaginationSchema.parse(request.query) + const result = await RbacService.listRoles(app.db, request.companyId, params) + return reply.send(result) + }) + + // Unpaginated list for dropdowns/selectors + app.get('/roles/all', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { + const data = await app.db + .select() + .from(roles) + .where(and(eq(roles.companyId, request.companyId), eq(roles.isActive, true))) + .orderBy(roles.name) return reply.send({ data }) }) diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index c0b2c47..eae745f 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -1,4 +1,4 @@ -import { eq, and, sql, count, exists } from 'drizzle-orm' +import { eq, and, sql, count, exists, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { accounts, @@ -30,11 +30,11 @@ import { } from '../utils/pagination.js' async function generateUniqueNumber( - db: PostgresJsDatabase, + db: PostgresJsDatabase, table: typeof accounts | typeof members, column: typeof accounts.accountNumber | typeof members.memberNumber, companyId: string, - companyIdColumn: typeof accounts.companyId, + companyIdColumn: Column, ): Promise { for (let attempt = 0; attempt < 10; attempt++) { const num = String(Math.floor(100000 + Math.random() * 900000)) @@ -58,7 +58,7 @@ function normalizeAddress(address?: { street?: string; city?: string; state?: st } export const AccountService = { - async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) { const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber, companyId, accounts.companyId) const [account] = await db @@ -78,7 +78,7 @@ export const AccountService = { return account }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [account] = await db .select() .from(accounts) @@ -88,7 +88,7 @@ export const AccountService = { return account ?? null }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) { const [account] = await db .update(accounts) .set({ ...input, updatedAt: new Date() }) @@ -98,7 +98,7 @@ export const AccountService = { return account ?? null }, - async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { const [account] = await db .update(accounts) .set({ isActive: false, updatedAt: new Date() }) @@ -108,7 +108,7 @@ export const AccountService = { return account ?? null }, - async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true)) const accountSearch = params.q @@ -133,7 +133,7 @@ export const AccountService = { const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere - const sortableColumns: Record = { + const sortableColumns: Record = { name: accounts.name, email: accounts.email, created_at: accounts.createdAt, @@ -155,7 +155,7 @@ export const AccountService = { export const MemberService = { async create( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, input: { accountId: string @@ -210,7 +210,7 @@ export const MemberService = { return member }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [member] = await db .select() .from(members) @@ -220,7 +220,7 @@ export const MemberService = { return member ?? null }, - async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = eq(members.companyId, companyId) const searchCondition = params.q @@ -229,7 +229,7 @@ export const MemberService = { const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere - const sortableColumns: Record = { + const sortableColumns: Record = { first_name: members.firstName, last_name: members.lastName, email: members.email, @@ -268,14 +268,14 @@ export const MemberService = { }, async listByAccount( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, accountId: string, params: PaginationInput, ) { const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId)) - const sortableColumns: Record = { + const sortableColumns: Record = { first_name: members.firstName, last_name: members.lastName, created_at: members.createdAt, @@ -294,7 +294,7 @@ export const MemberService = { }, async update( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, id: string, input: { @@ -325,7 +325,7 @@ export const MemberService = { return member ?? null }, - async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) { + async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) { const member = await this.getById(db, companyId, memberId) if (!member) return null @@ -351,7 +351,7 @@ export const MemberService = { return updated }, - async delete(db: PostgresJsDatabase, companyId: string, id: string) { + async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [member] = await db .delete(members) .where(and(eq(members.id, id), eq(members.companyId, companyId))) @@ -362,7 +362,7 @@ export const MemberService = { } export const ProcessorLinkService = { - async create(db: PostgresJsDatabase, companyId: string, input: ProcessorLinkCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: ProcessorLinkCreateInput) { const [link] = await db .insert(accountProcessorLinks) .values({ @@ -375,7 +375,7 @@ export const ProcessorLinkService = { return link }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [link] = await db .select() .from(accountProcessorLinks) @@ -384,19 +384,31 @@ export const ProcessorLinkService = { return link ?? null }, - async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { - return db - .select() - .from(accountProcessorLinks) - .where( - and( - eq(accountProcessorLinks.companyId, companyId), - eq(accountProcessorLinks.accountId, accountId), - ), - ) + async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string, params: PaginationInput) { + const baseWhere = and(eq(accountProcessorLinks.companyId, companyId), eq(accountProcessorLinks.accountId, accountId)) + const searchCondition = params.q + ? buildSearchCondition(params.q, [accountProcessorLinks.processorCustomerId, accountProcessorLinks.processor]) + : undefined + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + processor: accountProcessorLinks.processor, + created_at: accountProcessorLinks.createdAt, + } + + let query = db.select().from(accountProcessorLinks).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, accountProcessorLinks.createdAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(accountProcessorLinks).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProcessorLinkUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProcessorLinkUpdateInput) { const [link] = await db .update(accountProcessorLinks) .set(input) @@ -405,7 +417,7 @@ export const ProcessorLinkService = { return link ?? null }, - async delete(db: PostgresJsDatabase, companyId: string, id: string) { + async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [link] = await db .delete(accountProcessorLinks) .where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) @@ -415,7 +427,7 @@ export const ProcessorLinkService = { } export const PaymentMethodService = { - async create(db: PostgresJsDatabase, companyId: string, input: PaymentMethodCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: PaymentMethodCreateInput) { // If this is the default, unset any existing default for this account if (input.isDefault) { await db @@ -447,7 +459,7 @@ export const PaymentMethodService = { return method }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [method] = await db .select() .from(accountPaymentMethods) @@ -456,19 +468,32 @@ export const PaymentMethodService = { return method ?? null }, - async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { - return db - .select() - .from(accountPaymentMethods) - .where( - and( - eq(accountPaymentMethods.companyId, companyId), - eq(accountPaymentMethods.accountId, accountId), - ), - ) + async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string, params: PaginationInput) { + const baseWhere = and(eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, accountId)) + const searchCondition = params.q + ? buildSearchCondition(params.q, [accountPaymentMethods.cardBrand, accountPaymentMethods.lastFour]) + : undefined + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + card_brand: accountPaymentMethods.cardBrand, + processor: accountPaymentMethods.processor, + created_at: accountPaymentMethods.createdAt, + } + + let query = db.select().from(accountPaymentMethods).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, accountPaymentMethods.createdAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(accountPaymentMethods).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: PaymentMethodUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: PaymentMethodUpdateInput) { // If setting as default, unset existing default if (input.isDefault) { const existing = await this.getById(db, companyId, id) @@ -494,7 +519,7 @@ export const PaymentMethodService = { return method ?? null }, - async delete(db: PostgresJsDatabase, companyId: string, id: string) { + async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [method] = await db .delete(accountPaymentMethods) .where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) @@ -504,7 +529,7 @@ export const PaymentMethodService = { } export const TaxExemptionService = { - async create(db: PostgresJsDatabase, companyId: string, input: TaxExemptionCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: TaxExemptionCreateInput) { const [exemption] = await db .insert(taxExemptions) .values({ @@ -521,7 +546,7 @@ export const TaxExemptionService = { return exemption }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [exemption] = await db .select() .from(taxExemptions) @@ -530,19 +555,33 @@ export const TaxExemptionService = { return exemption ?? null }, - async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { - return db - .select() - .from(taxExemptions) - .where( - and( - eq(taxExemptions.companyId, companyId), - eq(taxExemptions.accountId, accountId), - ), - ) + async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string, params: PaginationInput) { + const baseWhere = and(eq(taxExemptions.companyId, companyId), eq(taxExemptions.accountId, accountId)) + const searchCondition = params.q + ? buildSearchCondition(params.q, [taxExemptions.certificateNumber, taxExemptions.certificateType, taxExemptions.issuingState]) + : undefined + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + certificate_number: taxExemptions.certificateNumber, + status: taxExemptions.status, + expires_at: taxExemptions.expiresAt, + created_at: taxExemptions.createdAt, + } + + let query = db.select().from(taxExemptions).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, taxExemptions.createdAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(taxExemptions).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: TaxExemptionUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: TaxExemptionUpdateInput) { const [exemption] = await db .update(taxExemptions) .set({ ...input, updatedAt: new Date() }) @@ -551,7 +590,7 @@ export const TaxExemptionService = { return exemption ?? null }, - async approve(db: PostgresJsDatabase, companyId: string, id: string, approvedBy: string) { + async approve(db: PostgresJsDatabase, companyId: string, id: string, approvedBy: string) { const [exemption] = await db .update(taxExemptions) .set({ @@ -565,7 +604,7 @@ export const TaxExemptionService = { return exemption ?? null }, - async revoke(db: PostgresJsDatabase, companyId: string, id: string, revokedBy: string, reason: string) { + async revoke(db: PostgresJsDatabase, companyId: string, id: string, revokedBy: string, reason: string) { const [exemption] = await db .update(taxExemptions) .set({ @@ -582,7 +621,7 @@ export const TaxExemptionService = { } export const MemberIdentifierService = { - async create(db: PostgresJsDatabase, companyId: string, input: MemberIdentifierCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: MemberIdentifierCreateInput) { // If setting as primary, unset existing primary for this member if (input.isPrimary) { await db @@ -616,19 +655,31 @@ export const MemberIdentifierService = { return identifier }, - async listByMember(db: PostgresJsDatabase, companyId: string, memberId: string) { - return db - .select() - .from(memberIdentifiers) - .where( - and( - eq(memberIdentifiers.companyId, companyId), - eq(memberIdentifiers.memberId, memberId), - ), - ) + async listByMember(db: PostgresJsDatabase, companyId: string, memberId: string, params: PaginationInput) { + const baseWhere = and(eq(memberIdentifiers.companyId, companyId), eq(memberIdentifiers.memberId, memberId)) + const searchCondition = params.q + ? buildSearchCondition(params.q, [memberIdentifiers.value, memberIdentifiers.label, memberIdentifiers.issuingAuthority]) + : undefined + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + type: memberIdentifiers.type, + created_at: memberIdentifiers.createdAt, + } + + let query = db.select().from(memberIdentifiers).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, memberIdentifiers.createdAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(memberIdentifiers).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [identifier] = await db .select() .from(memberIdentifiers) @@ -637,7 +688,7 @@ export const MemberIdentifierService = { return identifier ?? null }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: MemberIdentifierUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: MemberIdentifierUpdateInput) { if (input.isPrimary) { const existing = await this.getById(db, companyId, id) if (existing) { @@ -661,7 +712,7 @@ export const MemberIdentifierService = { return identifier ?? null }, - async delete(db: PostgresJsDatabase, companyId: string, id: string) { + async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [identifier] = await db .delete(memberIdentifiers) .where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) diff --git a/packages/backend/src/services/file.service.ts b/packages/backend/src/services/file.service.ts index fb277ca..e8fd001 100644 --- a/packages/backend/src/services/file.service.ts +++ b/packages/backend/src/services/file.service.ts @@ -24,7 +24,7 @@ function getExtension(contentType: string): string { export const FileService = { async upload( - db: PostgresJsDatabase, + db: PostgresJsDatabase, storage: StorageProvider, companyId: string, input: { @@ -91,7 +91,7 @@ export const FileService = { return file }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [file] = await db .select() .from(files) @@ -101,7 +101,7 @@ export const FileService = { }, async listByEntity( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, entityType: string, entityId: string, @@ -120,7 +120,7 @@ export const FileService = { }, async delete( - db: PostgresJsDatabase, + db: PostgresJsDatabase, storage: StorageProvider, companyId: string, id: string, diff --git a/packages/backend/src/services/inventory.service.ts b/packages/backend/src/services/inventory.service.ts index 854ac8a..e4a0360 100644 --- a/packages/backend/src/services/inventory.service.ts +++ b/packages/backend/src/services/inventory.service.ts @@ -1,4 +1,4 @@ -import { eq, and, count } from 'drizzle-orm' +import { eq, and, count, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { categories, suppliers } from '../db/schema/inventory.js' import type { @@ -16,7 +16,7 @@ import { } from '../utils/pagination.js' export const CategoryService = { - async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: CategoryCreateInput) { const [category] = await db .insert(categories) .values({ companyId, ...input }) @@ -24,7 +24,7 @@ export const CategoryService = { return category }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [category] = await db .select() .from(categories) @@ -33,7 +33,7 @@ export const CategoryService = { return category ?? null }, - async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = and(eq(categories.companyId, companyId), eq(categories.isActive, true)) const searchCondition = params.q @@ -42,7 +42,7 @@ export const CategoryService = { const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere - const sortableColumns: Record = { + const sortableColumns: Record = { name: categories.name, sort_order: categories.sortOrder, created_at: categories.createdAt, @@ -60,7 +60,7 @@ export const CategoryService = { return paginatedResponse(data, total, params.page, params.limit) }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: CategoryUpdateInput) { const [category] = await db .update(categories) .set({ ...input, updatedAt: new Date() }) @@ -69,7 +69,7 @@ export const CategoryService = { return category ?? null }, - async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { const [category] = await db .update(categories) .set({ isActive: false, updatedAt: new Date() }) @@ -80,7 +80,7 @@ export const CategoryService = { } export const SupplierService = { - async create(db: PostgresJsDatabase, companyId: string, input: SupplierCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: SupplierCreateInput) { const [supplier] = await db .insert(suppliers) .values({ companyId, ...input }) @@ -88,7 +88,7 @@ export const SupplierService = { return supplier }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [supplier] = await db .select() .from(suppliers) @@ -97,7 +97,7 @@ export const SupplierService = { return supplier ?? null }, - async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = and(eq(suppliers.companyId, companyId), eq(suppliers.isActive, true)) const searchCondition = params.q @@ -106,7 +106,7 @@ export const SupplierService = { const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere - const sortableColumns: Record = { + const sortableColumns: Record = { name: suppliers.name, created_at: suppliers.createdAt, } @@ -123,7 +123,7 @@ export const SupplierService = { return paginatedResponse(data, total, params.page, params.limit) }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: SupplierUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: SupplierUpdateInput) { const [supplier] = await db .update(suppliers) .set({ ...input, updatedAt: new Date() }) @@ -132,7 +132,7 @@ export const SupplierService = { return supplier ?? null }, - async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { const [supplier] = await db .update(suppliers) .set({ isActive: false, updatedAt: new Date() }) diff --git a/packages/backend/src/services/lookup.service.ts b/packages/backend/src/services/lookup.service.ts index d4155c2..131690a 100644 --- a/packages/backend/src/services/lookup.service.ts +++ b/packages/backend/src/services/lookup.service.ts @@ -14,7 +14,7 @@ function createLookupService( systemSeeds: ReadonlyArray<{ slug: string; name: string; description: string; sortOrder: number }>, ) { return { - async seedForCompany(db: PostgresJsDatabase, companyId: string) { + async seedForCompany(db: PostgresJsDatabase, companyId: string) { const existing = await db .select() .from(table) @@ -32,7 +32,7 @@ function createLookupService( ) }, - async list(db: PostgresJsDatabase, companyId: string) { + async list(db: PostgresJsDatabase, companyId: string) { return db .select() .from(table) @@ -40,7 +40,7 @@ function createLookupService( .orderBy(table.sortOrder) }, - async getBySlug(db: PostgresJsDatabase, companyId: string, slug: string) { + async getBySlug(db: PostgresJsDatabase, companyId: string, slug: string) { const [row] = await db .select() .from(table) @@ -49,7 +49,7 @@ function createLookupService( return row ?? null }, - async create(db: PostgresJsDatabase, companyId: string, input: LookupCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: LookupCreateInput) { const [row] = await db .insert(table) .values({ @@ -64,7 +64,7 @@ function createLookupService( return row }, - async update(db: PostgresJsDatabase, companyId: string, id: string, input: LookupUpdateInput) { + async update(db: PostgresJsDatabase, companyId: string, id: string, input: LookupUpdateInput) { // Prevent modifying system rows' slug or system flag const existing = await db .select() @@ -85,7 +85,7 @@ function createLookupService( return row ?? null }, - async delete(db: PostgresJsDatabase, companyId: string, id: string) { + async delete(db: PostgresJsDatabase, companyId: string, id: string) { const existing = await db .select() .from(table) @@ -104,7 +104,7 @@ function createLookupService( return row ?? null }, - async validateSlug(db: PostgresJsDatabase, companyId: string, slug: string): Promise { + async validateSlug(db: PostgresJsDatabase, companyId: string, slug: string): Promise { const row = await this.getBySlug(db, companyId, slug) return row !== null && row.isActive }, diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts index 2ee0790..d458aac 100644 --- a/packages/backend/src/services/product.service.ts +++ b/packages/backend/src/services/product.service.ts @@ -1,4 +1,4 @@ -import { eq, and, count } from 'drizzle-orm' +import { eq, and, count, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { products, inventoryUnits, priceHistory } from '../db/schema/inventory.js' import { ValidationError } from '../lib/errors.js' @@ -18,7 +18,7 @@ import { import { UnitStatusService, ItemConditionService } from './lookup.service.js' export const ProductService = { - async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) { const [product] = await db .insert(products) .values({ @@ -32,7 +32,7 @@ export const ProductService = { return product }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [product] = await db .select() .from(products) @@ -41,7 +41,7 @@ export const ProductService = { return product ?? null }, - async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = and(eq(products.companyId, companyId), eq(products.isActive, true)) const searchCondition = params.q @@ -50,7 +50,7 @@ export const ProductService = { const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere - const sortableColumns: Record = { + const sortableColumns: Record = { name: products.name, sku: products.sku, brand: products.brand, @@ -71,7 +71,7 @@ export const ProductService = { }, async update( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, id: string, input: ProductUpdateInput, @@ -106,7 +106,7 @@ export const ProductService = { return product ?? null }, - async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { + async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { const [product] = await db .update(products) .set({ isActive: false, updatedAt: new Date() }) @@ -117,7 +117,7 @@ export const ProductService = { } export const InventoryUnitService = { - async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) { + async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) { if (input.condition) { const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) if (!valid) throw new ValidationError(`Invalid condition: "${input.condition}"`) @@ -144,7 +144,7 @@ export const InventoryUnitService = { return unit }, - async getById(db: PostgresJsDatabase, companyId: string, id: string) { + async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [unit] = await db .select() .from(inventoryUnits) @@ -154,7 +154,7 @@ export const InventoryUnitService = { }, async listByProduct( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, productId: string, params: PaginationInput, @@ -164,7 +164,7 @@ export const InventoryUnitService = { eq(inventoryUnits.productId, productId), ) - const sortableColumns: Record = { + const sortableColumns: Record = { serial_number: inventoryUnits.serialNumber, status: inventoryUnits.status, condition: inventoryUnits.condition, @@ -184,7 +184,7 @@ export const InventoryUnitService = { }, async update( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, id: string, input: InventoryUnitUpdateInput, diff --git a/packages/backend/src/services/rbac.service.ts b/packages/backend/src/services/rbac.service.ts index b64857b..172badd 100644 --- a/packages/backend/src/services/rbac.service.ts +++ b/packages/backend/src/services/rbac.service.ts @@ -1,12 +1,14 @@ -import { eq, and, inArray } from 'drizzle-orm' +import { eq, and, inArray, count, type Column } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import type { PaginationInput } from '@forte/shared/schemas' import { permissions, roles, rolePermissions, userRoles } from '../db/schema/rbac.js' import { SYSTEM_PERMISSIONS, DEFAULT_ROLES } from '../db/seeds/rbac.js' import { ForbiddenError } from '../lib/errors.js' +import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' export const RbacService = { /** Seed system permissions (global, run once) */ - async seedPermissions(db: PostgresJsDatabase) { + async seedPermissions(db: PostgresJsDatabase) { const existing = await db.select({ slug: permissions.slug }).from(permissions) const existingSlugs = new Set(existing.map((p) => p.slug)) @@ -17,7 +19,7 @@ export const RbacService = { }, /** Seed default roles for a company */ - async seedRolesForCompany(db: PostgresJsDatabase, companyId: string) { + async seedRolesForCompany(db: PostgresJsDatabase, companyId: string) { const existingRoles = await db .select({ slug: roles.slug }) .from(roles) @@ -57,7 +59,7 @@ export const RbacService = { }, /** Get all permissions for a user (union of all role permissions) */ - async getUserPermissions(db: PostgresJsDatabase, userId: string): Promise { + async getUserPermissions(db: PostgresJsDatabase, userId: string): Promise { const userRoleRecords = await db .select({ roleId: userRoles.roleId }) .from(userRoles) @@ -85,21 +87,40 @@ export const RbacService = { }, /** List all permissions */ - async listPermissions(db: PostgresJsDatabase) { + async listPermissions(db: PostgresJsDatabase) { return db.select().from(permissions).orderBy(permissions.domain, permissions.action) }, /** List roles for a company */ - async listRoles(db: PostgresJsDatabase, companyId: string) { - return db - .select() - .from(roles) - .where(and(eq(roles.companyId, companyId), eq(roles.isActive, true))) - .orderBy(roles.name) + async listRoles(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { + const baseWhere = and(eq(roles.companyId, companyId), eq(roles.isActive, true)) + + const searchCondition = params.q + ? buildSearchCondition(params.q, [roles.name, roles.slug]) + : undefined + + const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere + + const sortableColumns: Record = { + name: roles.name, + slug: roles.slug, + created_at: roles.createdAt, + } + + let query = db.select().from(roles).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, roles.name) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(roles).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) }, /** Get role with its permissions */ - async getRoleWithPermissions(db: PostgresJsDatabase, companyId: string, roleId: string) { + async getRoleWithPermissions(db: PostgresJsDatabase, companyId: string, roleId: string) { const [role] = await db .select() .from(roles) @@ -119,7 +140,7 @@ export const RbacService = { /** Create a custom role */ async createRole( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, input: { name: string; slug: string; description?: string; permissionSlugs: string[] }, ) { @@ -139,7 +160,7 @@ export const RbacService = { }, /** Update role permissions (replace all) */ - async setRolePermissions(db: PostgresJsDatabase, roleId: string, permissionSlugs: string[]) { + async setRolePermissions(db: PostgresJsDatabase, roleId: string, permissionSlugs: string[]) { // Delete existing await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)) @@ -160,7 +181,7 @@ export const RbacService = { /** Update a role */ async updateRole( - db: PostgresJsDatabase, + db: PostgresJsDatabase, companyId: string, roleId: string, input: { name?: string; description?: string; permissionSlugs?: string[] }, @@ -184,7 +205,7 @@ export const RbacService = { }, /** Delete a custom role */ - async deleteRole(db: PostgresJsDatabase, companyId: string, roleId: string) { + async deleteRole(db: PostgresJsDatabase, companyId: string, roleId: string) { const [role] = await db .select() .from(roles) @@ -207,7 +228,7 @@ export const RbacService = { }, /** Assign a role to a user */ - async assignRole(db: PostgresJsDatabase, userId: string, roleId: string, assignedBy?: string) { + async assignRole(db: PostgresJsDatabase, userId: string, roleId: string, assignedBy?: string) { const [existing] = await db .select() .from(userRoles) @@ -225,7 +246,7 @@ export const RbacService = { }, /** Remove a role from a user */ - async removeRole(db: PostgresJsDatabase, userId: string, roleId: string) { + async removeRole(db: PostgresJsDatabase, userId: string, roleId: string) { const [removed] = await db .delete(userRoles) .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))) @@ -235,7 +256,7 @@ export const RbacService = { }, /** Get roles assigned to a user */ - async getUserRoles(db: PostgresJsDatabase, userId: string) { + async getUserRoles(db: PostgresJsDatabase, userId: string) { return db .select({ id: roles.id, diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts index a82a188..7bdb00c 100644 --- a/packages/shared/src/schemas/account.schema.ts +++ b/packages/shared/src/schemas/account.schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod' /** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ function opt(schema: T) { - return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) as z.ZodEffects> + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) } export const BillingMode = z.enum(['consolidated', 'split']) diff --git a/packages/shared/src/schemas/inventory.schema.ts b/packages/shared/src/schemas/inventory.schema.ts index 96d882b..f79306a 100644 --- a/packages/shared/src/schemas/inventory.schema.ts +++ b/packages/shared/src/schemas/inventory.schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod' /** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ function opt(schema: T) { - return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) as z.ZodEffects> + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) } export const CategoryCreateSchema = z.object({