Initial commit — eLegal Software monorepo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-04-26 02:42:42 -04:00
co-authored by Claude Sonnet 4.6
commit 0700d54225
160 changed files with 22771 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useMutation } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export function useDeleteAccount() {
return useMutation<void, ApiError, { password: string }>({
mutationFn: (body) => api.post('/api/account/delete', body),
});
}
// Triggers a download of the export JSON. Uses a direct link so the browser handles the file save.
export async function downloadAccountExport(): Promise<void> {
const csrf = readCookie('csrf');
const res = await fetch('/api/account/export', {
credentials: 'same-origin',
headers: csrf ? { 'X-CSRF-Token': csrf } : undefined,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(body || `export_failed_${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lawdesk-export-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function readCookie(name: string): string | undefined {
if (typeof document === 'undefined') return undefined;
const prefix = `${name}=`;
for (const part of document.cookie.split('; ')) {
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
}
return undefined;
}
+201
View File
@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export type AdminPlan = 'starter' | 'pro' | 'lifetime';
export interface AdminStats {
counters: {
firms: number;
users: number;
cases: number;
clients: number;
invoices: number;
unresolvedContact: number;
paidRevenueTotal: string;
};
planDistribution: Array<{ plan: AdminPlan; count: number }>;
signupsLast30Days: Array<{ day: string; count: number }>;
}
export interface AdminFirmListItem {
id: string;
name: string;
plan: AdminPlan;
watermarkEnabled: boolean;
createdAt: string;
userCount: number;
caseCount: number;
clientCount: number;
paidTotal: string;
}
export interface AdminFirmDetailResponse {
firm: {
id: string;
name: string;
plan: AdminPlan;
watermarkEnabled: boolean;
storageBytesUsed: number;
createdAt: string;
updatedAt: string;
trialEndsAt: string | null;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
};
users: Array<{
id: string;
email: string;
fullName: string | null;
role: string;
isSuspended: boolean;
isSuperadmin: boolean;
createdAt: string;
lastSeenAt: string | null;
}>;
counts: {
clients: number;
cases: number;
invoices: number;
paidTotal: string;
};
}
export interface AdminUserListItem {
id: string;
email: string;
fullName: string | null;
role: string;
isSuperadmin: boolean;
isSuspended: boolean;
createdAt: string;
lastSeenAt: string | null;
firmId: string | null;
firmName: string | null;
}
export interface AdminContactMessage {
id: string;
fullName: string;
email: string;
message: string;
ip: string | null;
resolvedAt: string | null;
createdAt: string;
}
export interface AdminAuditEntry {
id: string;
userId: string | null;
firmId: string | null;
action: string;
meta: string | null;
ip: string | null;
createdAt: string;
userEmail: string | null;
}
export function useAdminStats() {
return useQuery<AdminStats>({
queryKey: ['admin', 'stats'],
queryFn: () => api.get('/api/admin/stats'),
refetchInterval: 60_000,
});
}
export function useAdminFirms(params: { q?: string; plan?: AdminPlan } = {}) {
return useQuery<{ items: AdminFirmListItem[]; total: number }>({
queryKey: ['admin', 'firms', params],
queryFn: () => {
const u = new URLSearchParams();
if (params.q) u.set('q', params.q);
if (params.plan) u.set('plan', params.plan);
const s = u.toString();
return api.get(`/api/admin/firms${s ? `?${s}` : ''}`);
},
});
}
export function useAdminFirm(id: string | undefined) {
return useQuery<AdminFirmDetailResponse>({
queryKey: id ? ['admin', 'firms', 'detail', id] : ['admin', 'firms', 'detail', 'noop'],
queryFn: () => api.get(`/api/admin/firms/${id}`),
enabled: !!id,
});
}
export function useUpdateAdminFirm(id: string) {
const qc = useQueryClient();
return useMutation<unknown, ApiError, { plan?: AdminPlan; watermarkEnabled?: boolean; name?: string }>({
mutationFn: (body) => api.patch(`/api/admin/firms/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin'] });
},
});
}
export function useAdminUsers(params: { q?: string; suspended?: 'true' | 'false' } = {}) {
return useQuery<{ items: AdminUserListItem[]; total: number }>({
queryKey: ['admin', 'users', params],
queryFn: () => {
const u = new URLSearchParams();
if (params.q) u.set('q', params.q);
if (params.suspended) u.set('suspended', params.suspended);
const s = u.toString();
return api.get(`/api/admin/users${s ? `?${s}` : ''}`);
},
});
}
export function useUpdateAdminUser(id: string) {
const qc = useQueryClient();
return useMutation<unknown, ApiError, { isSuspended?: boolean; role?: string }>({
mutationFn: (body) => api.patch(`/api/admin/users/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin'] });
},
});
}
export function useImpersonate() {
const qc = useQueryClient();
return useMutation<unknown, ApiError, string>({
mutationFn: (id) => api.post(`/api/admin/users/${id}/impersonate`),
onSuccess: () => {
qc.invalidateQueries();
},
});
}
export function useAdminContactMessages(params: { resolved?: 'true' | 'false' } = {}) {
return useQuery<{ items: AdminContactMessage[]; total: number }>({
queryKey: ['admin', 'contact', params],
queryFn: () => {
const u = new URLSearchParams();
if (params.resolved) u.set('resolved', params.resolved);
const s = u.toString();
return api.get(`/api/admin/contact-messages${s ? `?${s}` : ''}`);
},
});
}
export function useResolveContactMessage() {
const qc = useQueryClient();
return useMutation<unknown, ApiError, { id: string; resolved: boolean }>({
mutationFn: ({ id, resolved }) => api.patch(`/api/admin/contact-messages/${id}`, { resolved }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'contact'] }),
});
}
export function useAuditLog(params: { action?: string; userId?: string; firmId?: string } = {}) {
return useQuery<{ items: AdminAuditEntry[] }>({
queryKey: ['admin', 'audit', params],
queryFn: () => {
const u = new URLSearchParams();
if (params.action) u.set('action', params.action);
if (params.userId) u.set('userId', params.userId);
if (params.firmId) u.set('firmId', params.firmId);
const s = u.toString();
return api.get(`/api/admin/audit-log${s ? `?${s}` : ''}`);
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export interface AuthUser {
id: string;
email: string;
fullName?: string | null;
firmId: string | null;
role: string;
isSuperadmin?: boolean;
isSuspended?: boolean;
}
interface MeResponse {
user: AuthUser;
}
const ME_KEY = ['auth', 'me'] as const;
export function useMe() {
return useQuery<AuthUser | null>({
queryKey: ME_KEY,
queryFn: async () => {
try {
const data = await api.get<MeResponse>('/api/auth/me');
return data.user;
} catch (err) {
if ((err as ApiError).status === 401) return null;
throw err;
}
},
staleTime: 60_000,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation<AuthUser, ApiError, { email: string; password: string }>({
mutationFn: async (vars) => {
const data = await api.post<MeResponse>('/api/auth/login', vars);
return data.user;
},
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
});
}
export function useSignup() {
const qc = useQueryClient();
return useMutation<AuthUser, ApiError, { email: string; password: string; fullName: string; firmName: string }>({
mutationFn: async (vars) => {
const data = await api.post<MeResponse>('/api/auth/signup', vars);
return data.user;
},
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation<void, ApiError>({
mutationFn: async () => {
await api.post('/api/auth/logout');
},
onSuccess: () => qc.setQueryData(ME_KEY, null),
});
}
+28
View File
@@ -0,0 +1,28 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export interface BillingStatus {
configured: boolean;
plan: 'starter' | 'pro' | 'lifetime';
hasSubscription: boolean;
hasCustomer: boolean;
}
export function useBillingStatus() {
return useQuery<BillingStatus>({
queryKey: ['billing', 'status'],
queryFn: () => api.get('/api/billing/status'),
});
}
export function useStartCheckout() {
return useMutation<{ url: string }, ApiError, { plan: 'pro' | 'lifetime' }>({
mutationFn: (body) => api.post('/api/billing/checkout', body),
});
}
export function useOpenPortal() {
return useMutation<{ url: string }, ApiError, void>({
mutationFn: () => api.post('/api/billing/portal'),
});
}
+108
View File
@@ -0,0 +1,108 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export type CaseStatus = 'open' | 'pending' | 'closed' | 'archived';
export interface CaseListItem {
id: string;
title: string;
caseNumber: string | null;
status: CaseStatus;
practiceArea: string | null;
hourlyRate: string | null;
openedAt: string;
clientId: string;
clientName: string;
billedMinutes: number;
}
export interface CaseDetail {
id: string;
title: string;
caseNumber: string | null;
status: CaseStatus;
practiceArea: string | null;
description: string | null;
hourlyRate: string | null;
openedAt: string;
closedAt: string | null;
clientId: string;
clientName: string;
clientEmail: string | null;
}
export interface CaseInput {
clientId: string;
title: string;
caseNumber?: string | null;
status?: CaseStatus;
practiceArea?: string | null;
description?: string | null;
hourlyRate?: number | null;
}
interface ListResponse {
items: CaseListItem[];
total: number;
}
interface ListParams {
q?: string;
status?: CaseStatus;
clientId?: string;
}
const KEY = {
list: (p: ListParams = {}) => ['cases', 'list', p] as const,
detail: (id: string) => ['cases', 'detail', id] as const,
};
function qs(p: ListParams): string {
const u = new URLSearchParams();
if (p.q) u.set('q', p.q);
if (p.status) u.set('status', p.status);
if (p.clientId) u.set('clientId', p.clientId);
const s = u.toString();
return s ? `?${s}` : '';
}
export function useCases(params: ListParams = {}) {
return useQuery<ListResponse>({
queryKey: KEY.list(params),
queryFn: () => api.get(`/api/cases${qs(params)}`),
});
}
export function useCase(id: string | undefined) {
return useQuery<CaseDetail>({
queryKey: id ? KEY.detail(id) : ['cases', 'detail', 'noop'],
queryFn: () => api.get(`/api/cases/${id}`),
enabled: !!id,
});
}
export function useCreateCase() {
const qc = useQueryClient();
return useMutation<CaseDetail, ApiError, CaseInput>({
mutationFn: (body) => api.post('/api/cases', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
});
}
export function useUpdateCase(id: string) {
const qc = useQueryClient();
return useMutation<CaseDetail, ApiError, Partial<CaseInput>>({
mutationFn: (body) => api.patch(`/api/cases/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cases'] });
},
});
}
export function useDeleteCase() {
const qc = useQueryClient();
return useMutation<void, ApiError, string>({
mutationFn: (id) => api.delete(`/api/cases/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
});
}
+82
View File
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export interface ClientListItem {
id: string;
name: string;
email: string | null;
phone: string | null;
createdAt: string;
caseCount: number;
}
export interface Client {
id: string;
firmId: string;
name: string;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface ClientInput {
name: string;
email?: string | null;
phone?: string | null;
address?: string | null;
notes?: string | null;
}
interface ListResponse {
items: ClientListItem[];
total: number;
}
const KEY = {
list: (q?: string) => ['clients', 'list', q ?? ''] as const,
detail: (id: string) => ['clients', 'detail', id] as const,
};
export function useClients(q?: string) {
return useQuery<ListResponse>({
queryKey: KEY.list(q),
queryFn: () => api.get(`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`),
});
}
export function useClient(id: string | undefined) {
return useQuery<Client>({
queryKey: id ? KEY.detail(id) : ['clients', 'detail', 'noop'],
queryFn: () => api.get(`/api/clients/${id}`),
enabled: !!id,
});
}
export function useCreateClient() {
const qc = useQueryClient();
return useMutation<Client, ApiError, ClientInput>({
mutationFn: (body) => api.post('/api/clients', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
});
}
export function useUpdateClient(id: string) {
const qc = useQueryClient();
return useMutation<Client, ApiError, Partial<ClientInput>>({
mutationFn: (body) => api.patch(`/api/clients/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients'] });
},
});
}
export function useDeleteClient() {
const qc = useQueryClient();
return useMutation<void, ApiError, string>({
mutationFn: (id) => api.delete(`/api/clients/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
});
}
+160
View File
@@ -0,0 +1,160 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void';
export interface InvoiceListItem {
id: string;
number: string;
status: InvoiceStatus;
total: string;
subtotal: string;
issuedAt: string | null;
dueAt: string | null;
paidAt: string | null;
createdAt: string;
clientId: string;
clientName: string;
caseId: string | null;
caseTitle: string | null;
}
export interface InvoiceItem {
id: string;
invoiceId: string;
description: string;
quantity: string;
rate: string;
amount: string;
sortOrder: number;
}
export interface InvoiceDetail {
id: string;
number: string;
status: InvoiceStatus;
subtotal: string;
taxRate: string;
total: string;
notes: string | null;
issuedAt: string | null;
dueAt: string | null;
paidAt: string | null;
createdAt: string;
clientId: string;
clientName: string;
clientEmail: string | null;
caseId: string | null;
caseTitle: string | null;
items: InvoiceItem[];
}
export interface CreateInvoiceInput {
clientId: string;
caseId?: string | null;
notes?: string | null;
taxRate?: number;
dueAt?: string | null;
items?: Array<{ description: string; quantity: number; rate: number }>;
timeEntryIds?: string[];
}
export interface UpdateInvoiceInput {
notes?: string | null;
taxRate?: number;
dueAt?: string | null;
}
export interface ListParams {
status?: InvoiceStatus;
clientId?: string;
caseId?: string;
}
interface ListResponse {
items: InvoiceListItem[];
total: number;
}
const KEY = {
list: (p: ListParams = {}) => ['invoices', 'list', p] as const,
detail: (id: string) => ['invoices', 'detail', id] as const,
};
function qs(p: ListParams): string {
const u = new URLSearchParams();
if (p.status) u.set('status', p.status);
if (p.clientId) u.set('clientId', p.clientId);
if (p.caseId) u.set('caseId', p.caseId);
const s = u.toString();
return s ? `?${s}` : '';
}
export function useInvoices(params: ListParams = {}) {
return useQuery<ListResponse>({
queryKey: KEY.list(params),
queryFn: () => api.get(`/api/invoices${qs(params)}`),
});
}
export function useInvoice(id: string | undefined) {
return useQuery<InvoiceDetail>({
queryKey: id ? KEY.detail(id) : ['invoices', 'detail', 'noop'],
queryFn: () => api.get(`/api/invoices/${id}`),
enabled: !!id,
});
}
export function useCreateInvoice() {
const qc = useQueryClient();
return useMutation<InvoiceDetail, ApiError, CreateInvoiceInput>({
mutationFn: (body) => api.post('/api/invoices', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] });
qc.invalidateQueries({ queryKey: ['time-entries'] });
},
});
}
export function useUpdateInvoice(id: string) {
const qc = useQueryClient();
return useMutation<InvoiceDetail, ApiError, UpdateInvoiceInput>({
mutationFn: (body) => api.patch(`/api/invoices/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
});
}
export function useSendInvoice() {
const qc = useQueryClient();
return useMutation<InvoiceDetail, ApiError, string>({
mutationFn: (id) => api.post(`/api/invoices/${id}/send`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
});
}
export function useMarkPaid() {
const qc = useQueryClient();
return useMutation<InvoiceDetail, ApiError, string>({
mutationFn: (id) => api.post(`/api/invoices/${id}/mark-paid`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
});
}
export function useVoidInvoice() {
const qc = useQueryClient();
return useMutation<InvoiceDetail, ApiError, string>({
mutationFn: (id) => api.post(`/api/invoices/${id}/void`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
});
}
export function useDeleteInvoice() {
const qc = useQueryClient();
return useMutation<void, ApiError, string>({
mutationFn: (id) => api.delete(`/api/invoices/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] });
qc.invalidateQueries({ queryKey: ['time-entries'] });
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useMutation } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export function useRequestPasswordReset() {
return useMutation<{ ok: boolean }, ApiError, { email: string }>({
mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
});
}
export function useResetPassword() {
return useMutation<{ ok: boolean }, ApiError, { token: string; password: string }>({
mutationFn: (body) => api.post('/api/auth/reset-password', body),
});
}
+134
View File
@@ -0,0 +1,134 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
export interface TimeEntry {
id: string;
caseId: string;
caseTitle: string;
clientId: string;
clientName: string;
userId: string;
description: string;
startedAt: string;
endedAt: string | null;
minutes: number;
rate: string;
billable: boolean;
invoiceItemId: string | null;
}
export interface ActiveTimer {
id: string;
caseId: string;
caseTitle: string;
clientName: string;
description: string;
startedAt: string;
rate: string;
}
export interface TimeListParams {
caseId?: string;
from?: string;
to?: string;
invoiced?: 'true' | 'false';
}
interface ListResponse {
items: TimeEntry[];
total: number;
}
const KEY = {
list: (p: TimeListParams = {}) => ['time-entries', 'list', p] as const,
active: ['time-entries', 'active'] as const,
};
function qs(p: TimeListParams): string {
const u = new URLSearchParams();
if (p.caseId) u.set('caseId', p.caseId);
if (p.from) u.set('from', p.from);
if (p.to) u.set('to', p.to);
if (p.invoiced) u.set('invoiced', p.invoiced);
const s = u.toString();
return s ? `?${s}` : '';
}
export function useTimeEntries(params: TimeListParams = {}) {
return useQuery<ListResponse>({
queryKey: KEY.list(params),
queryFn: () => api.get(`/api/time-entries${qs(params)}`),
});
}
export function useActiveTimer() {
return useQuery<{ active: ActiveTimer | null }>({
queryKey: KEY.active,
queryFn: () => api.get('/api/time-entries/active'),
refetchInterval: 60_000,
});
}
export function useStartTimer() {
const qc = useQueryClient();
return useMutation<TimeEntry, ApiError, { caseId: string; description?: string }>({
mutationFn: (body) => api.post('/api/time-entries/start', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['time-entries'] });
},
});
}
export function useStopTimer() {
const qc = useQueryClient();
return useMutation<TimeEntry, ApiError, string>({
mutationFn: (id) => api.post(`/api/time-entries/${id}/stop`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['time-entries'] });
qc.invalidateQueries({ queryKey: ['cases'] });
},
});
}
export interface ManualEntryInput {
caseId: string;
description: string;
startedAt: string;
endedAt?: string | null;
minutes?: number;
rate?: number;
billable?: boolean;
}
export function useCreateTimeEntry() {
const qc = useQueryClient();
return useMutation<TimeEntry, ApiError, ManualEntryInput>({
mutationFn: (body) => api.post('/api/time-entries', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['time-entries'] });
qc.invalidateQueries({ queryKey: ['cases'] });
},
});
}
export function useUpdateTimeEntry(id: string) {
const qc = useQueryClient();
return useMutation<TimeEntry, ApiError, Partial<ManualEntryInput>>({
mutationFn: (body) => api.patch(`/api/time-entries/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['time-entries'] });
qc.invalidateQueries({ queryKey: ['cases'] });
},
});
}
export function useDeleteTimeEntry() {
const qc = useQueryClient();
return useMutation<void, ApiError, string>({
mutationFn: (id) => api.delete(`/api/time-entries/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['time-entries'] });
qc.invalidateQueries({ queryKey: ['cases'] });
},
});
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
export type ToolName =
| 'hourly-rate-calculator'
| 'case-profitability'
| 'billable-hours-tracker'
| 'document-templates';
const SESSION_KEY = 'lawdesk:tool-session';
function getSessionId(): string {
if (typeof sessionStorage === 'undefined') return '';
let v = sessionStorage.getItem(SESSION_KEY);
if (!v) {
v = crypto.randomUUID();
sessionStorage.setItem(SESSION_KEY, v);
}
return v;
}
// Fire-and-forget: log a hit when a tool page mounts, then ping every 90 seconds
// so the visitor counts as "online" until they leave the page.
export function useTrackTool(tool: ToolName): void {
useEffect(() => {
const sessionId = getSessionId();
const ping = () =>
api.post('/api/tool-usage', { tool, sessionId }).catch(() => {});
ping();
const id = setInterval(ping, 90_000);
return () => clearInterval(id);
}, [tool]);
}
export function useToolsOnline() {
return useQuery<{ online: Record<string, number>; since: string }>({
queryKey: ['tools', 'online'],
queryFn: () => api.get('/api/tool-usage/online'),
refetchInterval: 30_000,
});
}