Improve input validation and data sanitization

The changes add consistent string trimming, better null handling, and
improved validation logic across the business and client forms.
This commit is contained in:
2025-08-11 02:48:24 -04:00
parent a680f89a46
commit 46767ca7e2
3 changed files with 167 additions and 112 deletions

View File

@@ -43,17 +43,6 @@ interface BusinessesDataTableProps {
businesses: Business[];
}
const formatAddress = (business: Business) => {
const parts = [
business.addressLine1,
business.addressLine2,
business.city,
business.state,
business.postalCode,
].filter(Boolean);
return parts.join(", ") || "—";
};
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const router = useRouter();
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
@@ -103,7 +92,7 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<div className="min-w-0">
<p className="truncate font-medium">{business.name}</p>
<p className="text-muted-foreground truncate text-sm">
{business.email ?? "—"}
{business.nickname ?? "—"}
</p>
</div>
</div>
@@ -111,11 +100,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
},
},
{
accessorKey: "nickname",
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nickname" />
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => row.original.nickname ?? "—",
cell: ({ row }) => row.original.email ?? "—",
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
@@ -132,26 +121,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
cellClassName: "hidden md:table-cell",
},
},
{
id: "address",
header: "Address",
cell: ({ row }) => formatAddress(row.original),
meta: {
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
{
accessorKey: "taxId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Tax ID" />
),
cell: ({ row }) => row.original.taxId ?? "—",
meta: {
headerClassName: "hidden xl:table-cell",
cellClassName: "hidden xl:table-cell",
},
},
{
accessorKey: "website",
header: ({ column }) => (

View File

@@ -123,29 +123,18 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Update email configuration mutation
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
onSuccess: () => {
toast.success("Email configuration updated successfully");
},
onError: (error) => {
toast.error(`Failed to update email configuration: ${error.message}`);
},
});
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
toast.success("Business created successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to create business");
},
});
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
toast.success("Business updated successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to update business");
},
@@ -203,69 +192,87 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
newErrors.name = VALIDATION_MESSAGES.required;
}
// Nickname validation (optional, max 255 chars)
if (formData.nickname && formData.nickname.length > 255) {
if (formData.nickname && formData.nickname.trim().length > 255) {
newErrors.nickname = "Nickname must be 255 characters or less";
}
// Email validation
if (formData.email && !isValidEmail(formData.email)) {
if (formData.email.trim() && !isValidEmail(formData.email.trim())) {
newErrors.email = VALIDATION_MESSAGES.email;
}
// Phone validation (basic check for US format)
if (formData.phone) {
if (formData.phone.trim()) {
const phoneDigits = formData.phone.replace(/\D/g, "");
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
newErrors.phone = VALIDATION_MESSAGES.phone;
}
}
// Address validation if any address field is filled
const hasAddressData =
formData.addressLine1 ||
formData.city ||
formData.state ||
formData.postalCode;
// Address validation if any address field is filled (excluding country as it has a default)
const hasAddressData = !!(
formData.addressLine1.trim() ||
formData.city.trim() ||
formData.state.trim() ||
formData.postalCode.trim()
);
if (hasAddressData) {
if (!formData.addressLine1)
// Also check if country was explicitly changed from default
const hasNonDefaultCountry =
formData.country.trim() && formData.country.trim() !== "United States";
const hasAnyAddressInput = hasAddressData || hasNonDefaultCountry;
// Only validate address if user has actually entered address data
if (hasAnyAddressInput) {
if (!formData.addressLine1.trim())
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
if (!formData.city.trim()) newErrors.city = VALIDATION_MESSAGES.required;
if (!formData.country.trim())
newErrors.country = VALIDATION_MESSAGES.required;
if (formData.country === "United States") {
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
if (!formData.postalCode)
// Only require US-specific fields if country is United States AND we have actual address data
if (formData.country.trim() === "United States" && hasAddressData) {
if (!formData.state.trim())
newErrors.state = VALIDATION_MESSAGES.required;
if (!formData.postalCode.trim())
newErrors.postalCode = VALIDATION_MESSAGES.required;
}
}
// Email configuration validation
// API Key validation
if (formData.resendApiKey && !formData.resendApiKey.startsWith("re_")) {
if (
formData.resendApiKey.trim() &&
!formData.resendApiKey.trim().startsWith("re_")
) {
newErrors.resendApiKey = "Resend API key should start with 're_'";
}
// Domain validation
if (formData.resendDomain) {
if (formData.resendDomain.trim()) {
const domainRegex =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/;
if (!domainRegex.test(formData.resendDomain)) {
if (!domainRegex.test(formData.resendDomain.trim())) {
newErrors.resendDomain =
"Please enter a valid domain (e.g., yourdomain.com)";
}
}
// If API key is provided, domain must also be provided
if (formData.resendApiKey && !formData.resendDomain) {
if (formData.resendApiKey.trim() && !formData.resendDomain.trim()) {
newErrors.resendDomain = "Domain is required when API key is provided";
}
// If domain is provided, API key must also be provided
// If domain is provided, API key must also be provided (unless there's already one on the server)
// In edit mode, if domain comes from server and API key field is empty, don't require new API key
const userEnteredDomain =
formData.resendDomain.trim() !== (emailConfig?.resendDomain ?? "");
if (
formData.resendDomain &&
!formData.resendApiKey &&
!emailConfig?.hasApiKey
formData.resendDomain.trim() &&
!formData.resendApiKey.trim() &&
!emailConfig?.hasApiKey &&
(mode === "create" || userEnteredDomain)
) {
newErrors.resendApiKey = "API key is required when domain is provided";
}
@@ -289,7 +296,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const dataToSubmit = {
...formData,
name: formData.name.trim(),
nickname: formData.nickname.trim(),
nickname: formData.nickname.trim() || undefined,
website: formData.website ? formatWebsiteUrl(formData.website) : "",
};
@@ -313,20 +320,24 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const newBusiness = await createBusiness.mutateAsync(businessData);
// Update email configuration separately if any email fields are provided
if (
newBusiness &&
(formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName)
) {
// Update email configuration separately if any email fields have values
const newApiKey = formData.resendApiKey.trim();
const newDomain = formData.resendDomain.trim();
const newFromName = formData.emailFromName.trim();
const hasEmailData = newApiKey || newDomain || newFromName;
if (newBusiness && hasEmailData) {
await updateEmailConfig.mutateAsync({
id: newBusiness.id,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
resendApiKey: newApiKey || undefined,
resendDomain: newDomain || undefined,
emailFromName: newFromName || undefined,
});
}
toast.success("Business created successfully");
router.push("/dashboard/businesses");
} else {
// Update business data (excluding email config fields)
const businessData = {
@@ -350,19 +361,31 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
...businessData,
});
// Update email configuration separately if any email fields are provided
if (
formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName
) {
// Only update email configuration if there are actual changes or new values
const currentApiKey = emailConfig?.hasApiKey ? "EXISTING" : "";
const currentDomain = emailConfig?.resendDomain ?? "";
const currentFromName = emailConfig?.emailFromName ?? "";
const newApiKey = formData.resendApiKey.trim();
const newDomain = formData.resendDomain.trim();
const newFromName = formData.emailFromName.trim();
const hasEmailChanges =
(newApiKey && newApiKey !== currentApiKey) ||
newDomain !== currentDomain ||
newFromName !== currentFromName;
if (hasEmailChanges) {
await updateEmailConfig.mutateAsync({
id: businessId!,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
resendApiKey: newApiKey || undefined,
resendDomain: newDomain || undefined,
emailFromName: newFromName || undefined,
});
}
toast.success("Business updated successfully");
router.push("/dashboard/businesses");
}
} finally {
setIsSubmitting(false);
@@ -667,7 +690,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="flex items-center justify-between bg-gray-50 p-4">
<div className="bg-muted/50 flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
@@ -688,7 +711,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
)}
</div>
{emailConfig?.resendDomain && (
<span className="text-sm text-gray-600">
<span className="text-muted-foreground text-sm">
Domain: {emailConfig.resendDomain}
</span>
)}
@@ -742,7 +765,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}
placeholder={
mode === "edit" && emailConfig?.hasApiKey
? "Enter new API key to update"
? "••••••••••••••••••••••••••••••••"
: "re_..."
}
className={

View File

@@ -108,19 +108,41 @@ export const clientsRouter = createTRPCRouter({
.input(createClientSchema)
.mutation(async ({ ctx, input }) => {
try {
// Clean up empty strings to null, but preserve required fields
const cleanInput = Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key,
value === "" ? null : value,
]),
);
const [client] = await ctx.db
.insert(clients)
.values({
name: input.name, // Ensure name is included
...cleanInput,
name: input.name.trim(),
email:
input.email && input.email.trim() !== ""
? input.email.trim()
: null,
phone:
input.phone && input.phone.trim() !== ""
? input.phone.trim()
: null,
addressLine1:
input.addressLine1 && input.addressLine1.trim() !== ""
? input.addressLine1.trim()
: null,
addressLine2:
input.addressLine2 && input.addressLine2.trim() !== ""
? input.addressLine2.trim()
: null,
city:
input.city && input.city.trim() !== "" ? input.city.trim() : null,
state:
input.state && input.state.trim() !== ""
? input.state.trim()
: null,
postalCode:
input.postalCode && input.postalCode.trim() !== ""
? input.postalCode.trim()
: null,
country:
input.country && input.country.trim() !== ""
? input.country.trim()
: null,
defaultHourlyRate: input.defaultHourlyRate ?? null,
createdById: ctx.session.user.id,
})
.returning();
@@ -168,18 +190,59 @@ export const clientsRouter = createTRPCRouter({
});
}
// Clean up empty strings to null
const cleanData = Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
value === "" ? null : value,
]),
);
const [updatedClient] = await ctx.db
.update(clients)
.set({
...cleanData,
name: data.name ? data.name.trim() : undefined,
email:
data.email !== undefined
? data.email && data.email.trim() !== ""
? data.email.trim()
: null
: undefined,
phone:
data.phone !== undefined
? data.phone && data.phone.trim() !== ""
? data.phone.trim()
: null
: undefined,
addressLine1:
data.addressLine1 !== undefined
? data.addressLine1 && data.addressLine1.trim() !== ""
? data.addressLine1.trim()
: null
: undefined,
addressLine2:
data.addressLine2 !== undefined
? data.addressLine2 && data.addressLine2.trim() !== ""
? data.addressLine2.trim()
: null
: undefined,
city:
data.city !== undefined
? data.city && data.city.trim() !== ""
? data.city.trim()
: null
: undefined,
state:
data.state !== undefined
? data.state && data.state.trim() !== ""
? data.state.trim()
: null
: undefined,
postalCode:
data.postalCode !== undefined
? data.postalCode && data.postalCode.trim() !== ""
? data.postalCode.trim()
: null
: undefined,
country:
data.country !== undefined
? data.country && data.country.trim() !== ""
? data.country.trim()
: null
: undefined,
defaultHourlyRate: data.defaultHourlyRate ?? undefined,
updatedAt: new Date(),
})
.where(eq(clients.id, id))