mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
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:
@@ -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 }) => (
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user