ppEmiliano Claude commited on
Commit
3ff6ff5
·
1 Parent(s): 2aaeb25

Fix phone validation and company delete error messaging

Browse files

Issue #3: Add phone number validation (regex: +?digits/spaces/dashes,
7-20 chars) to both contact and company forms with inline error display.
Validation runs client-side and server-side via Zod schema.

Issue #4: Check for linked contacts before deleting a company. Returns
a 409 with a descriptive error message shown as a toast instead of
silently failing.

Closes McGill-NLP/webarena-pro#3
Closes McGill-NLP/webarena-pro#4

Co-Authored-By: Claude <noreply@anthropic.com>

Dockerfile CHANGED
@@ -40,6 +40,8 @@ RUN npm run build
40
  # ============================================
41
  FROM node:20-slim AS runner
42
 
 
 
43
  WORKDIR /app
44
 
45
  ENV NODE_ENV=production
 
40
  # ============================================
41
  FROM node:20-slim AS runner
42
 
43
+ LABEL org.opencontainers.image.source=https://github.com/McGill-NLP/webarena-pro
44
+
45
  WORKDIR /app
46
 
47
  ENV NODE_ENV=production
src/app/(app)/companies/page.tsx CHANGED
@@ -36,6 +36,7 @@ export default function CompaniesPage() {
36
  const [editing, setEditing] = useState<Company | null>(null);
37
  const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
38
  const [loading, setLoading] = useState(false);
 
39
 
40
  const load = useCallback(async () => {
41
  const res = await fetch(`/api/companies?search=${encodeURIComponent(search)}&limit=100`);
@@ -50,11 +51,20 @@ export default function CompaniesPage() {
50
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
51
  e.preventDefault();
52
  setLoading(true);
 
53
  const form = new FormData(e.currentTarget);
 
 
 
 
 
 
 
 
54
  const data = {
55
  name: form.get("name") as string,
56
  domain: (form.get("domain") as string) || null,
57
- phone: (form.get("phone") as string) || null,
58
  address: (form.get("address") as string) || null,
59
  notes: (form.get("notes") as string) || null,
60
  };
@@ -73,6 +83,9 @@ export default function CompaniesPage() {
73
  setShowForm(false);
74
  setEditing(null);
75
  load();
 
 
 
76
  }
77
  }
78
 
@@ -82,6 +95,9 @@ export default function CompaniesPage() {
82
  if (res.ok) {
83
  toast.success("Company deleted");
84
  load();
 
 
 
85
  }
86
  setDeleteTarget(null);
87
  }
@@ -119,7 +135,7 @@ export default function CompaniesPage() {
119
  <div>
120
  <div className="flex items-center justify-between mb-4">
121
  <h1 className="text-2xl font-bold">Companies</h1>
122
- <Button onClick={() => { setEditing(null); setShowForm(true); }}>
123
  <Plus className="h-4 w-4 mr-2" />
124
  Add Company
125
  </Button>
@@ -157,7 +173,8 @@ export default function CompaniesPage() {
157
  </div>
158
  <div className="space-y-2">
159
  <Label htmlFor="phone">Phone</Label>
160
- <Input id="phone" name="phone" defaultValue={editing?.phone || ""} />
 
161
  </div>
162
  <div className="space-y-2">
163
  <Label htmlFor="address">Address</Label>
 
36
  const [editing, setEditing] = useState<Company | null>(null);
37
  const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
38
  const [loading, setLoading] = useState(false);
39
+ const [formError, setFormError] = useState("");
40
 
41
  const load = useCallback(async () => {
42
  const res = await fetch(`/api/companies?search=${encodeURIComponent(search)}&limit=100`);
 
51
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
52
  e.preventDefault();
53
  setLoading(true);
54
+ setFormError("");
55
  const form = new FormData(e.currentTarget);
56
+ const phone = (form.get("phone") as string) || null;
57
+
58
+ if (phone && !/^\+?[\d\s\-().]{7,20}$/.test(phone)) {
59
+ setFormError("Invalid phone number");
60
+ setLoading(false);
61
+ return;
62
+ }
63
+
64
  const data = {
65
  name: form.get("name") as string,
66
  domain: (form.get("domain") as string) || null,
67
+ phone,
68
  address: (form.get("address") as string) || null,
69
  notes: (form.get("notes") as string) || null,
70
  };
 
83
  setShowForm(false);
84
  setEditing(null);
85
  load();
86
+ } else {
87
+ const err = await res.json().catch(() => null);
88
+ toast.error(err?.error?.fieldErrors?.phone?.[0] || "Failed to save company");
89
  }
90
  }
91
 
 
95
  if (res.ok) {
96
  toast.success("Company deleted");
97
  load();
98
+ } else {
99
+ const err = await res.json().catch(() => null);
100
+ toast.error(err?.error || "Failed to delete company");
101
  }
102
  setDeleteTarget(null);
103
  }
 
135
  <div>
136
  <div className="flex items-center justify-between mb-4">
137
  <h1 className="text-2xl font-bold">Companies</h1>
138
+ <Button onClick={() => { setEditing(null); setFormError(""); setShowForm(true); }}>
139
  <Plus className="h-4 w-4 mr-2" />
140
  Add Company
141
  </Button>
 
173
  </div>
174
  <div className="space-y-2">
175
  <Label htmlFor="phone">Phone</Label>
176
+ <Input id="phone" name="phone" defaultValue={editing?.phone || ""} placeholder="+1-555-0100" />
177
+ {formError && <p className="text-sm text-destructive">{formError}</p>}
178
  </div>
179
  <div className="space-y-2">
180
  <Label htmlFor="address">Address</Label>
src/app/(app)/contacts/page.tsx CHANGED
@@ -45,6 +45,7 @@ export default function ContactsPage() {
45
  const [editing, setEditing] = useState<Contact | null>(null);
46
  const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
47
  const [loading, setLoading] = useState(false);
 
48
 
49
  const load = useCallback(async () => {
50
  const res = await fetch(`/api/contacts?search=${encodeURIComponent(search)}&limit=100`);
@@ -63,13 +64,22 @@ export default function ContactsPage() {
63
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
64
  e.preventDefault();
65
  setLoading(true);
 
66
  const form = new FormData(e.currentTarget);
67
  const companyIdVal = form.get("companyId") as string;
 
 
 
 
 
 
 
 
68
  const data = {
69
  firstName: form.get("firstName") as string,
70
  lastName: form.get("lastName") as string,
71
  email: (form.get("email") as string) || null,
72
- phone: (form.get("phone") as string) || null,
73
  jobTitle: (form.get("jobTitle") as string) || null,
74
  companyId: companyIdVal && companyIdVal !== "none" ? parseInt(companyIdVal) : null,
75
  };
@@ -88,6 +98,9 @@ export default function ContactsPage() {
88
  setShowForm(false);
89
  setEditing(null);
90
  load();
 
 
 
91
  }
92
  }
93
 
@@ -138,7 +151,7 @@ export default function ContactsPage() {
138
  <div>
139
  <div className="flex items-center justify-between mb-4">
140
  <h1 className="text-2xl font-bold">Contacts</h1>
141
- <Button onClick={() => { setEditing(null); setShowForm(true); }}>
142
  <Plus className="h-4 w-4 mr-2" />
143
  Add Contact
144
  </Button>
@@ -182,7 +195,8 @@ export default function ContactsPage() {
182
  </div>
183
  <div className="space-y-2">
184
  <Label htmlFor="phone">Phone</Label>
185
- <Input id="phone" name="phone" defaultValue={editing?.phone || ""} />
 
186
  </div>
187
  <div className="space-y-2">
188
  <Label htmlFor="jobTitle">Job Title</Label>
 
45
  const [editing, setEditing] = useState<Contact | null>(null);
46
  const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
47
  const [loading, setLoading] = useState(false);
48
+ const [formError, setFormError] = useState("");
49
 
50
  const load = useCallback(async () => {
51
  const res = await fetch(`/api/contacts?search=${encodeURIComponent(search)}&limit=100`);
 
64
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
65
  e.preventDefault();
66
  setLoading(true);
67
+ setFormError("");
68
  const form = new FormData(e.currentTarget);
69
  const companyIdVal = form.get("companyId") as string;
70
+ const phone = (form.get("phone") as string) || null;
71
+
72
+ if (phone && !/^\+?[\d\s\-().]{7,20}$/.test(phone)) {
73
+ setFormError("Invalid phone number");
74
+ setLoading(false);
75
+ return;
76
+ }
77
+
78
  const data = {
79
  firstName: form.get("firstName") as string,
80
  lastName: form.get("lastName") as string,
81
  email: (form.get("email") as string) || null,
82
+ phone,
83
  jobTitle: (form.get("jobTitle") as string) || null,
84
  companyId: companyIdVal && companyIdVal !== "none" ? parseInt(companyIdVal) : null,
85
  };
 
98
  setShowForm(false);
99
  setEditing(null);
100
  load();
101
+ } else {
102
+ const err = await res.json().catch(() => null);
103
+ toast.error(err?.error?.fieldErrors?.phone?.[0] || "Failed to save contact");
104
  }
105
  }
106
 
 
151
  <div>
152
  <div className="flex items-center justify-between mb-4">
153
  <h1 className="text-2xl font-bold">Contacts</h1>
154
+ <Button onClick={() => { setEditing(null); setFormError(""); setShowForm(true); }}>
155
  <Plus className="h-4 w-4 mr-2" />
156
  Add Contact
157
  </Button>
 
195
  </div>
196
  <div className="space-y-2">
197
  <Label htmlFor="phone">Phone</Label>
198
+ <Input id="phone" name="phone" defaultValue={editing?.phone || ""} placeholder="+1-555-0100" />
199
+ {formError && <p className="text-sm text-destructive">{formError}</p>}
200
  </div>
201
  <div className="space-y-2">
202
  <Label htmlFor="jobTitle">Job Title</Label>
src/app/api/companies/[id]/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
  import { db } from "@/lib/db";
3
- import { companies } from "@/lib/db/schema";
4
  import { companySchema } from "@/lib/validators";
5
  import { auth } from "@/lib/auth";
6
  import { eq, and } from "drizzle-orm";
@@ -45,9 +45,24 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
45
  if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
46
 
47
  const { id } = await params;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  const deleted = db
49
  .delete(companies)
50
- .where(and(eq(companies.id, parseInt(id)), eq(companies.userId, parseInt(session.user.id))))
51
  .returning()
52
  .all();
53
 
 
1
  import { NextRequest, NextResponse } from "next/server";
2
  import { db } from "@/lib/db";
3
+ import { companies, contacts } from "@/lib/db/schema";
4
  import { companySchema } from "@/lib/validators";
5
  import { auth } from "@/lib/auth";
6
  import { eq, and } from "drizzle-orm";
 
45
  if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
46
 
47
  const { id } = await params;
48
+ const companyId = parseInt(id);
49
+
50
+ const linkedContacts = db
51
+ .select({ id: contacts.id })
52
+ .from(contacts)
53
+ .where(eq(contacts.companyId, companyId))
54
+ .all();
55
+
56
+ if (linkedContacts.length > 0) {
57
+ return NextResponse.json(
58
+ { error: `Cannot delete this company because ${linkedContacts.length} contact${linkedContacts.length > 1 ? "s are" : " is"} still linked to it. Remove or reassign them first.` },
59
+ { status: 409 }
60
+ );
61
+ }
62
+
63
  const deleted = db
64
  .delete(companies)
65
+ .where(and(eq(companies.id, companyId), eq(companies.userId, parseInt(session.user.id))))
66
  .returning()
67
  .all();
68
 
src/lib/validators.ts CHANGED
@@ -1,9 +1,19 @@
1
  import { z } from "zod";
2
 
 
 
 
 
 
 
 
 
 
 
3
  export const companySchema = z.object({
4
  name: z.string().min(1, "Name is required"),
5
  domain: z.string().optional().nullable(),
6
- phone: z.string().optional().nullable(),
7
  address: z.string().optional().nullable(),
8
  notes: z.string().optional().nullable(),
9
  });
@@ -12,7 +22,7 @@ export const contactSchema = z.object({
12
  firstName: z.string().min(1, "First name is required"),
13
  lastName: z.string().min(1, "Last name is required"),
14
  email: z.string().email().optional().nullable().or(z.literal("")),
15
- phone: z.string().optional().nullable(),
16
  jobTitle: z.string().optional().nullable(),
17
  companyId: z.number().optional().nullable(),
18
  });
 
1
  import { z } from "zod";
2
 
3
+ const phoneRegex = /^\+?[\d\s\-().]{7,20}$/;
4
+
5
+ const phoneField = z
6
+ .string()
7
+ .refine((val) => !val || phoneRegex.test(val), {
8
+ message: "Invalid phone number",
9
+ })
10
+ .optional()
11
+ .nullable();
12
+
13
  export const companySchema = z.object({
14
  name: z.string().min(1, "Name is required"),
15
  domain: z.string().optional().nullable(),
16
+ phone: phoneField,
17
  address: z.string().optional().nullable(),
18
  notes: z.string().optional().nullable(),
19
  });
 
22
  firstName: z.string().min(1, "First name is required"),
23
  lastName: z.string().min(1, "Last name is required"),
24
  email: z.string().email().optional().nullable().or(z.literal("")),
25
+ phone: phoneField,
26
  jobTitle: z.string().optional().nullable(),
27
  companyId: z.number().optional().nullable(),
28
  });