Archived
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[];
|
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) {
|
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||||
@@ -103,7 +92,7 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{business.name}</p>
|
<p className="truncate font-medium">{business.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{business.email ?? "—"}
|
{business.nickname ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,11 +100,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "nickname",
|
accessorKey: "email",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Nickname" />
|
<DataTableColumnHeader column={column} title="Email" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.nickname ?? "—",
|
cell: ({ row }) => row.original.email ?? "—",
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -132,26 +121,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
cellClassName: "hidden md:table-cell",
|
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",
|
accessorKey: "website",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
@@ -123,29 +123,18 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
|
|
||||||
// Update email configuration mutation
|
// Update email configuration mutation
|
||||||
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
|
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Email configuration updated successfully");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to update email configuration: ${error.message}`);
|
toast.error(`Failed to update email configuration: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createBusiness = api.businesses.create.useMutation({
|
const createBusiness = api.businesses.create.useMutation({
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Business created successfully");
|
|
||||||
router.push("/dashboard/businesses");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to create business");
|
toast.error(error.message || "Failed to create business");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateBusiness = api.businesses.update.useMutation({
|
const updateBusiness = api.businesses.update.useMutation({
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Business updated successfully");
|
|
||||||
router.push("/dashboard/businesses");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to update business");
|
toast.error(error.message || "Failed to update business");
|
||||||
},
|
},
|
||||||
@@ -203,69 +192,87 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
newErrors.name = VALIDATION_MESSAGES.required;
|
newErrors.name = VALIDATION_MESSAGES.required;
|
||||||
}
|
}
|
||||||
// Nickname validation (optional, max 255 chars)
|
// 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";
|
newErrors.nickname = "Nickname must be 255 characters or less";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email validation
|
// Email validation
|
||||||
if (formData.email && !isValidEmail(formData.email)) {
|
if (formData.email.trim() && !isValidEmail(formData.email.trim())) {
|
||||||
newErrors.email = VALIDATION_MESSAGES.email;
|
newErrors.email = VALIDATION_MESSAGES.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phone validation (basic check for US format)
|
// Phone validation (basic check for US format)
|
||||||
if (formData.phone) {
|
if (formData.phone.trim()) {
|
||||||
const phoneDigits = formData.phone.replace(/\D/g, "");
|
const phoneDigits = formData.phone.replace(/\D/g, "");
|
||||||
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
|
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
|
||||||
newErrors.phone = VALIDATION_MESSAGES.phone;
|
newErrors.phone = VALIDATION_MESSAGES.phone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address validation if any address field is filled
|
// Address validation if any address field is filled (excluding country as it has a default)
|
||||||
const hasAddressData =
|
const hasAddressData = !!(
|
||||||
formData.addressLine1 ||
|
formData.addressLine1.trim() ||
|
||||||
formData.city ||
|
formData.city.trim() ||
|
||||||
formData.state ||
|
formData.state.trim() ||
|
||||||
formData.postalCode;
|
formData.postalCode.trim()
|
||||||
|
);
|
||||||
|
|
||||||
if (hasAddressData) {
|
// Also check if country was explicitly changed from default
|
||||||
if (!formData.addressLine1)
|
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;
|
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
|
||||||
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
|
if (!formData.city.trim()) newErrors.city = VALIDATION_MESSAGES.required;
|
||||||
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
|
if (!formData.country.trim())
|
||||||
|
newErrors.country = VALIDATION_MESSAGES.required;
|
||||||
|
|
||||||
if (formData.country === "United States") {
|
// Only require US-specific fields if country is United States AND we have actual address data
|
||||||
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
|
if (formData.country.trim() === "United States" && hasAddressData) {
|
||||||
if (!formData.postalCode)
|
if (!formData.state.trim())
|
||||||
|
newErrors.state = VALIDATION_MESSAGES.required;
|
||||||
|
if (!formData.postalCode.trim())
|
||||||
newErrors.postalCode = VALIDATION_MESSAGES.required;
|
newErrors.postalCode = VALIDATION_MESSAGES.required;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email configuration validation
|
// Email configuration validation
|
||||||
// API Key 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_'";
|
newErrors.resendApiKey = "Resend API key should start with 're_'";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain validation
|
// Domain validation
|
||||||
if (formData.resendDomain) {
|
if (formData.resendDomain.trim()) {
|
||||||
const domainRegex =
|
const domainRegex =
|
||||||
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/;
|
/^[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 =
|
newErrors.resendDomain =
|
||||||
"Please enter a valid domain (e.g., yourdomain.com)";
|
"Please enter a valid domain (e.g., yourdomain.com)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If API key is provided, domain must also be provided
|
// 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";
|
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 (
|
if (
|
||||||
formData.resendDomain &&
|
formData.resendDomain.trim() &&
|
||||||
!formData.resendApiKey &&
|
!formData.resendApiKey.trim() &&
|
||||||
!emailConfig?.hasApiKey
|
!emailConfig?.hasApiKey &&
|
||||||
|
(mode === "create" || userEnteredDomain)
|
||||||
) {
|
) {
|
||||||
newErrors.resendApiKey = "API key is required when domain is provided";
|
newErrors.resendApiKey = "API key is required when domain is provided";
|
||||||
}
|
}
|
||||||
@@ -289,7 +296,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
const dataToSubmit = {
|
const dataToSubmit = {
|
||||||
...formData,
|
...formData,
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
nickname: formData.nickname.trim(),
|
nickname: formData.nickname.trim() || undefined,
|
||||||
website: formData.website ? formatWebsiteUrl(formData.website) : "",
|
website: formData.website ? formatWebsiteUrl(formData.website) : "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,20 +320,24 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
|
|
||||||
const newBusiness = await createBusiness.mutateAsync(businessData);
|
const newBusiness = await createBusiness.mutateAsync(businessData);
|
||||||
|
|
||||||
// Update email configuration separately if any email fields are provided
|
// Update email configuration separately if any email fields have values
|
||||||
if (
|
const newApiKey = formData.resendApiKey.trim();
|
||||||
newBusiness &&
|
const newDomain = formData.resendDomain.trim();
|
||||||
(formData.resendApiKey ||
|
const newFromName = formData.emailFromName.trim();
|
||||||
formData.resendDomain ||
|
|
||||||
formData.emailFromName)
|
const hasEmailData = newApiKey || newDomain || newFromName;
|
||||||
) {
|
|
||||||
|
if (newBusiness && hasEmailData) {
|
||||||
await updateEmailConfig.mutateAsync({
|
await updateEmailConfig.mutateAsync({
|
||||||
id: newBusiness.id,
|
id: newBusiness.id,
|
||||||
resendApiKey: formData.resendApiKey || undefined,
|
resendApiKey: newApiKey || undefined,
|
||||||
resendDomain: formData.resendDomain || undefined,
|
resendDomain: newDomain || undefined,
|
||||||
emailFromName: formData.emailFromName || undefined,
|
emailFromName: newFromName || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Business created successfully");
|
||||||
|
router.push("/dashboard/businesses");
|
||||||
} else {
|
} else {
|
||||||
// Update business data (excluding email config fields)
|
// Update business data (excluding email config fields)
|
||||||
const businessData = {
|
const businessData = {
|
||||||
@@ -350,19 +361,31 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
...businessData,
|
...businessData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update email configuration separately if any email fields are provided
|
// Only update email configuration if there are actual changes or new values
|
||||||
if (
|
const currentApiKey = emailConfig?.hasApiKey ? "EXISTING" : "";
|
||||||
formData.resendApiKey ||
|
const currentDomain = emailConfig?.resendDomain ?? "";
|
||||||
formData.resendDomain ||
|
const currentFromName = emailConfig?.emailFromName ?? "";
|
||||||
formData.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({
|
await updateEmailConfig.mutateAsync({
|
||||||
id: businessId!,
|
id: businessId!,
|
||||||
resendApiKey: formData.resendApiKey || undefined,
|
resendApiKey: newApiKey || undefined,
|
||||||
resendDomain: formData.resendDomain || undefined,
|
resendDomain: newDomain || undefined,
|
||||||
emailFromName: formData.emailFromName || undefined,
|
emailFromName: newFromName || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Business updated successfully");
|
||||||
|
router.push("/dashboard/businesses");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -667,7 +690,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Current Status */}
|
{/* Current Status */}
|
||||||
{mode === "edit" && (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Current Status:
|
Current Status:
|
||||||
@@ -688,7 +711,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{emailConfig?.resendDomain && (
|
{emailConfig?.resendDomain && (
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-muted-foreground text-sm">
|
||||||
Domain: {emailConfig.resendDomain}
|
Domain: {emailConfig.resendDomain}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -742,7 +765,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
}
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
mode === "edit" && emailConfig?.hasApiKey
|
mode === "edit" && emailConfig?.hasApiKey
|
||||||
? "Enter new API key to update"
|
? "••••••••••••••••••••••••••••••••"
|
||||||
: "re_..."
|
: "re_..."
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -108,19 +108,41 @@ export const clientsRouter = createTRPCRouter({
|
|||||||
.input(createClientSchema)
|
.input(createClientSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
try {
|
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
|
const [client] = await ctx.db
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values({
|
||||||
name: input.name, // Ensure name is included
|
name: input.name.trim(),
|
||||||
...cleanInput,
|
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,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.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
|
const [updatedClient] = await ctx.db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.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(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(clients.id, id))
|
.where(eq(clients.id, id))
|
||||||
|
|||||||
Reference in New Issue
Block a user