From 433e5dc53a3a8e80e5682f8c8bea1a5027871a04 Mon Sep 17 00:00:00 2001 From: elsiedp Date: Tue, 31 Mar 2026 16:38:32 +0800 Subject: [PATCH 1/3] Update URL validation schemas and enhance modal functionality - Updated the `AdminAOsModal` to simplify query invalidation after updating AOs. - Introduced comprehensive URL validation schemas for website, Facebook, Instagram, and Twitter in the validators package. - Added tests for the new URL schemas to ensure proper validation of various URL formats. --- .../app/_components/modal/admin-aos-modal.tsx | 6 +- packages/api/src/router/url-schemas.test.ts | 177 ++++++++++++++++ packages/validators/src/index.ts | 194 ++++++++++++++---- 3 files changed, 335 insertions(+), 42 deletions(-) create mode 100644 packages/api/src/router/url-schemas.test.ts diff --git a/apps/map/src/app/_components/modal/admin-aos-modal.tsx b/apps/map/src/app/_components/modal/admin-aos-modal.tsx index 1e835f43..6d0b93a3 100644 --- a/apps/map/src/app/_components/modal/admin-aos-modal.tsx +++ b/apps/map/src/app/_components/modal/admin-aos-modal.tsx @@ -125,11 +125,7 @@ export default function AdminAOsModal({ await crupdateAO .mutateAsync({ ...data, orgType: "ao" }) .then(() => { - void invalidateQueries( - orpc.org.all.queryOptions({ - input: { orgTypes: ["ao"] }, - }), - ); + void invalidateQueries("org"); closeModal(); toast.success("Successfully updated ao"); router.refresh(); diff --git a/packages/api/src/router/url-schemas.test.ts b/packages/api/src/router/url-schemas.test.ts new file mode 100644 index 00000000..18889085 --- /dev/null +++ b/packages/api/src/router/url-schemas.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import type { ZodTypeAny } from "zod"; + +import { + facebookUrlSchema, + instagramUrlSchema, + twitterUrlSchema, + websiteUrlSchema, +} from "@acme/validators"; + +const pass = (schema: ZodTypeAny, value: string) => + expect(schema.safeParse(value).success, `expected "${value}" to pass`).toBe( + true, + ); + +const fail = (schema: ZodTypeAny, value: string) => + expect(schema.safeParse(value).success, `expected "${value}" to fail`).toBe( + false, + ); + +describe("websiteUrlSchema", () => { + it("accepts empty string", () => pass(websiteUrlSchema, "")); + + it("accepts valid https URLs", () => { + pass(websiteUrlSchema, "https://example.com"); + pass(websiteUrlSchema, "https://www.example.com"); + pass(websiteUrlSchema, "https://example.co.uk"); + pass(websiteUrlSchema, "https://sub.domain.example.com/path"); + pass(websiteUrlSchema, "https://my-site.dev"); + }); + + it("accepts valid http URLs", () => { + pass(websiteUrlSchema, "http://example.com"); + }); + + it("rejects incomplete or invalid domains", () => { + fail(websiteUrlSchema, "https://bob"); + fail(websiteUrlSchema, "https://localhost"); + fail(websiteUrlSchema, "https://example"); + fail(websiteUrlSchema, "https://example."); + fail(websiteUrlSchema, "https://example.1"); + }); + + it("rejects hostnames starting with dot", () => { + fail(websiteUrlSchema, "https://.example.com"); + }); + + it("rejects non-http protocols", () => { + fail(websiteUrlSchema, "ftp://example.com"); + fail(websiteUrlSchema, "ht://bob"); + }); + + it("rejects non-URL strings", () => { + fail(websiteUrlSchema, "not a url"); + fail(websiteUrlSchema, "example.com"); + }); +}); + +describe("facebookUrlSchema", () => { + it("accepts empty string", () => pass(facebookUrlSchema, "")); + + it("accepts valid Facebook profile/page URLs", () => { + pass(facebookUrlSchema, "https://facebook.com/mypage"); + pass(facebookUrlSchema, "https://www.facebook.com/mypage"); + pass(facebookUrlSchema, "https://m.facebook.com/mypage"); + pass(facebookUrlSchema, "https://facebook.com/profile.php?id=12345"); + }); + + it("accepts Facebook homepage", () => { + pass(facebookUrlSchema, "https://facebook.com"); + pass(facebookUrlSchema, "https://www.facebook.com/"); + pass(facebookUrlSchema, "https://m.facebook.com/"); + }); + + it("rejects non-Facebook URLs", () => { + fail(facebookUrlSchema, "https://example.com"); + fail(facebookUrlSchema, "https://notfacebook.com/mypage"); + }); + + it("rejects share/sharer links", () => { + fail( + facebookUrlSchema, + "https://facebook.com/sharer/sharer.php?u=https://example.com", + ); + fail(facebookUrlSchema, "https://facebook.com/share?url=https://x.com"); + fail(facebookUrlSchema, "https://www.facebook.com/share/17edENZtXt/"); + }); + + it("rejects incomplete or invalid domains", () => { + fail(facebookUrlSchema, "https://facebook"); + fail(facebookUrlSchema, "https://www.facebook"); + }); + + it("rejects non-http protocols", () => { + fail(facebookUrlSchema, "ftp://facebook.com/mypage"); + }); + + it("rejects non-URL strings", () => { + fail(facebookUrlSchema, "facebook.com/mypage"); + fail(facebookUrlSchema, "@mypage"); + }); +}); + +describe("instagramUrlSchema", () => { + it("accepts empty string", () => pass(instagramUrlSchema, "")); + + it("accepts valid Instagram profile URLs", () => { + pass(instagramUrlSchema, "https://instagram.com/myhandle"); + pass(instagramUrlSchema, "https://www.instagram.com/myhandle"); + }); + + it("accepts Instagram homepage", () => { + pass(instagramUrlSchema, "https://instagram.com"); + pass(instagramUrlSchema, "https://www.instagram.com/"); + }); + + it("rejects non-Instagram URLs", () => { + fail(instagramUrlSchema, "https://example.com"); + fail(instagramUrlSchema, "https://notinstagram.com/myhandle"); + }); + + it("rejects reel/post/explore/stories links", () => { + fail(instagramUrlSchema, "https://instagram.com/reel/abc123"); + fail(instagramUrlSchema, "https://www.instagram.com/reel/abc123"); + fail(instagramUrlSchema, "https://instagram.com/p/abc123"); + fail(instagramUrlSchema, "https://instagram.com/explore"); + fail(instagramUrlSchema, "https://instagram.com/stories/user/123"); + }); + + it("rejects incomplete or invalid domains", () => { + fail(instagramUrlSchema, "https://instagram"); + fail(instagramUrlSchema, "https://www.instagram"); + }); + + it("rejects non-URL strings", () => { + fail(instagramUrlSchema, "instagram.com/myhandle"); + fail(instagramUrlSchema, "@myhandle"); + }); +}); + +describe("twitterUrlSchema", () => { + it("accepts empty string", () => pass(twitterUrlSchema, "")); + + it("accepts valid Twitter/X profile URLs", () => { + pass(twitterUrlSchema, "https://twitter.com/myhandle"); + pass(twitterUrlSchema, "https://www.twitter.com/myhandle"); + pass(twitterUrlSchema, "https://x.com/myhandle"); + pass(twitterUrlSchema, "https://www.x.com/myhandle"); + }); + + it("accepts Twitter/X homepage", () => { + pass(twitterUrlSchema, "https://twitter.com"); + pass(twitterUrlSchema, "https://x.com/"); + }); + + it("rejects non-Twitter/X URLs", () => { + fail(twitterUrlSchema, "https://example.com"); + fail(twitterUrlSchema, "https://nottwitter.com/myhandle"); + fail(twitterUrlSchema, "https://notx.com/myhandle"); + }); + + it("rejects intent/status links", () => { + fail(twitterUrlSchema, "https://twitter.com/intent/tweet?text=hello"); + fail(twitterUrlSchema, "https://x.com/user/status/12345"); + fail(twitterUrlSchema, "https://twitter.com/user/status/12345"); + }); + + it("rejects incomplete or invalid domains", () => { + fail(twitterUrlSchema, "https://x"); + fail(twitterUrlSchema, "https://twitter"); + }); + + it("rejects non-URL strings", () => { + fail(twitterUrlSchema, "twitter.com/myhandle"); + fail(twitterUrlSchema, "@myhandle"); + }); +}); diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index aa99427a..9c4788e0 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -98,16 +98,149 @@ export const CreateEventSchema = EventInsertSchema.omit({ }); export type EventInsertType = z.infer; +const emptyOr = (schema: z.ZodTypeAny) => z.union([z.literal(""), schema]); + +const httpUrl = z + .string() + .trim() + .url("Please enter a valid URL") + .refine( + (value) => value.startsWith("http://") || value.startsWith("https://"), + { + message: "Please enter the full link, including https://", + }, + ); + +export const websiteUrlSchema = z.union([ + z.literal(""), + z + .string() + .trim() + .url("Please enter a valid URL") + .refine( + (value) => { + try { + const url = new URL(value); + const { hostname } = url; + + return ( + (value.startsWith("http://") || value.startsWith("https://")) && + /\.[a-zA-Z]{2,}$/.test(hostname) && + !hostname.startsWith(".") + ); + } catch { + return false; + } + }, + { + message: "Please enter a valid website URL (e.g. https://example.com)", + }, + ), +]); + +export const facebookUrlSchema = emptyOr( + httpUrl.refine( + (value) => { + try { + const url = new URL(value); + const host = url.hostname.toLowerCase(); + const path = url.pathname.replace(/\/+$/, ""); + + const isFacebookHost = + host === "facebook.com" || + host === "www.facebook.com" || + host === "m.facebook.com"; + + if (!isFacebookHost) return false; + + if (path.startsWith("/share")) return false; + if (path.startsWith("/sharer")) return false; + + return true; + } catch { + return false; + } + }, + { + message: "Please enter a valid Facebook profile or page URL", + }, + ), +); + +export const instagramUrlSchema = emptyOr( + httpUrl.refine( + (value) => { + try { + const url = new URL(value); + const host = url.hostname.toLowerCase(); + const path = url.pathname.replace(/\/+$/, ""); + + const isInstagramHost = + host === "instagram.com" || host === "www.instagram.com"; + + if (!isInstagramHost) return false; + + if ( + path.startsWith("/reel") || + path.startsWith("/p/") || + path.startsWith("/explore") || + path.startsWith("/stories") + ) { + return false; + } + + return true; + } catch { + return false; + } + }, + { + message: "Please enter a valid Instagram profile URL", + }, + ), +); + +export const twitterUrlSchema = emptyOr( + httpUrl.refine( + (value) => { + try { + const url = new URL(value); + const host = url.hostname.toLowerCase(); + const path = url.pathname.replace(/\/+$/, ""); + + const isTwitterHost = + host === "twitter.com" || + host === "www.twitter.com" || + host === "x.com" || + host === "www.x.com"; + + if (!isTwitterHost) return false; + + if (path.startsWith("/intent") || path.includes("/status/")) { + return false; + } + + return true; + } catch { + return false; + } + }, + { + message: "Please enter a valid X/Twitter profile URL", + }, + ), +); + // NATION SCHEMA export const NationInsertSchema = createInsertSchema(orgs, { name: (s: z.ZodString) => s.min(1, { message: "Name is required" }), email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), description: (s: z.ZodString) => s.nullable(), - website: (s: z.ZodString) => s.nullable(), - twitter: (s: z.ZodString) => s.nullable(), - facebook: (s: z.ZodString) => s.nullable(), - instagram: (s: z.ZodString) => s.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), parentId: z.null({ message: "Must not have a parent" }).optional(), }).omit({ orgType: true }); export const NationSelectSchema = createSelectSchema(orgs); @@ -121,10 +254,10 @@ export const SectorInsertSchema = createInsertSchema(orgs, { email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), description: (s: z.ZodString) => s.nullable(), - website: (s: z.ZodString) => s.nullable(), - twitter: (s: z.ZodString) => s.nullable(), - facebook: (s: z.ZodString) => s.nullable(), - instagram: (s: z.ZodString) => s.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), }).omit({ orgType: true }); export const SectorSelectSchema = createSelectSchema(orgs); @@ -137,26 +270,13 @@ export const AreaInsertSchema = createInsertSchema(orgs, { email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), description: (s: z.ZodString) => s.nullable(), - website: (s: z.ZodString) => s.nullable(), - twitter: (s: z.ZodString) => s.nullable(), - facebook: (s: z.ZodString) => s.nullable(), - instagram: (s: z.ZodString) => s.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), }).omit({ orgType: true }); export const AreaSelectSchema = createSelectSchema(orgs); -const socialUrlSchema = z - .string() - .trim() - .refine( - (value) => - value === "" || - value.startsWith("http://") || - value.startsWith("https://"), - { - message: "Please enter the full link, including https://", - }, - ); - // REGION SCHEMA export const RegionInsertSchema = createInsertSchema(orgs, { name: (s: z.ZodString) => s.min(1, { message: "Name is required" }), @@ -166,10 +286,10 @@ export const RegionInsertSchema = createInsertSchema(orgs, { email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), description: (s: z.ZodString) => s.nullable(), - website: socialUrlSchema.nullable(), - twitter: socialUrlSchema.nullable(), - facebook: socialUrlSchema.nullable(), - instagram: socialUrlSchema.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), }).omit({ orgType: true }); export const RegionSelectSchema = createSelectSchema(orgs); @@ -182,10 +302,10 @@ export const AOInsertSchema = createInsertSchema(orgs, { email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), description: (s: z.ZodString) => s.nullable(), - website: (s: z.ZodString) => s.nullable(), - twitter: (s: z.ZodString) => s.nullable(), - facebook: (s: z.ZodString) => s.nullable(), - instagram: (s: z.ZodString) => s.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), }).omit({ orgType: true }); export const AOSelectSchema = createSelectSchema(orgs); @@ -199,10 +319,10 @@ export const OrgInsertSchema = createInsertSchema(orgs, { description: (s: z.ZodString) => s.nullable(), email: (s: z.ZodString) => s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), - website: (s: z.ZodString) => s.nullable(), - twitter: (s: z.ZodString) => s.nullable(), - facebook: (s: z.ZodString) => s.nullable(), - instagram: (s: z.ZodString) => s.nullable(), + website: websiteUrlSchema.nullable(), + twitter: twitterUrlSchema.nullable(), + facebook: facebookUrlSchema.nullable(), + instagram: instagramUrlSchema.nullable(), }); export const OrgSelectSchema = createSelectSchema(orgs); From 7ed7df51d514f77a5a458788b8839347837f9d10 Mon Sep 17 00:00:00 2001 From: elsiedp Date: Tue, 31 Mar 2026 20:45:19 +0800 Subject: [PATCH 2/3] Updated the `render` prop in `FormField` components for Twitter, Facebook, and Instagram to include explicit TypeScript type definitions for the `field` parameter, enhancing type safety and clarity. --- apps/map/src/app/_components/modal/admin-aos-modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/map/src/app/_components/modal/admin-aos-modal.tsx b/apps/map/src/app/_components/modal/admin-aos-modal.tsx index 6d0b93a3..07fe6f52 100644 --- a/apps/map/src/app/_components/modal/admin-aos-modal.tsx +++ b/apps/map/src/app/_components/modal/admin-aos-modal.tsx @@ -259,7 +259,7 @@ export default function AdminAOsModal({ ( + render={({ field }: { field: { value: string | null } }) => ( Twitter @@ -278,7 +278,7 @@ export default function AdminAOsModal({ ( + render={({ field }: { field: { value: string | null } }) => ( Facebook @@ -297,7 +297,7 @@ export default function AdminAOsModal({ ( + render={({ field }: { field: { value: string | null } }) => ( Instagram From ab547ce8b579c37c36b545d3326db9ff5f9a1daa Mon Sep 17 00:00:00 2001 From: elsiedp Date: Tue, 31 Mar 2026 23:42:44 +0800 Subject: [PATCH 3/3] Update URL validation schemas for Facebook, Instagram, and Twitter to improve clarity and validation logic, ensuring proper handling of optional URLs and enhancing user experience with more descriptive error messages. --- .../app/_components/modal/admin-aos-modal.tsx | 6 +- packages/validators/src/index.ts | 105 ++++++++++-------- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/apps/map/src/app/_components/modal/admin-aos-modal.tsx b/apps/map/src/app/_components/modal/admin-aos-modal.tsx index 07fe6f52..6d0b93a3 100644 --- a/apps/map/src/app/_components/modal/admin-aos-modal.tsx +++ b/apps/map/src/app/_components/modal/admin-aos-modal.tsx @@ -259,7 +259,7 @@ export default function AdminAOsModal({ ( + render={({ field }) => ( Twitter @@ -278,7 +278,7 @@ export default function AdminAOsModal({ ( + render={({ field }) => ( Facebook @@ -297,7 +297,7 @@ export default function AdminAOsModal({ ( + render={({ field }) => ( Instagram diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 9c4788e0..1bb89ba6 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -98,50 +98,50 @@ export const CreateEventSchema = EventInsertSchema.omit({ }); export type EventInsertType = z.infer; -const emptyOr = (schema: z.ZodTypeAny) => z.union([z.literal(""), schema]); - -const httpUrl = z +export const websiteUrlSchema = z .string() .trim() - .url("Please enter a valid URL") + .transform((v) => v || null) .refine( - (value) => value.startsWith("http://") || value.startsWith("https://"), + (value) => { + if (value === null) return true; + + try { + const url = new URL(value); + const { hostname } = url; + + return ( + (value.startsWith("http://") || value.startsWith("https://")) && + /\.[a-zA-Z]{2,}$/.test(hostname) && + !hostname.startsWith(".") + ); + } catch { + return false; + } + }, { - message: "Please enter the full link, including https://", + message: "Please enter a valid URL (e.g. https://www.example.com)", }, ); -export const websiteUrlSchema = z.union([ - z.literal(""), - z - .string() - .trim() - .url("Please enter a valid URL") - .refine( - (value) => { - try { - const url = new URL(value); - const { hostname } = url; - - return ( - (value.startsWith("http://") || value.startsWith("https://")) && - /\.[a-zA-Z]{2,}$/.test(hostname) && - !hostname.startsWith(".") - ); - } catch { - return false; - } - }, - { - message: "Please enter a valid website URL (e.g. https://example.com)", - }, - ), -]); +const normalizeOptionalUrl = (value: string) => { + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +}; + +const hasHttpProtocol = (value: string) => + value.startsWith("http://") || value.startsWith("https://"); -export const facebookUrlSchema = emptyOr( - httpUrl.refine( +export const facebookUrlSchema = z + .string() + .transform(normalizeOptionalUrl) + .refine( (value) => { + if (value === null) return true; + try { + if (!hasHttpProtocol(value)) return false; + const url = new URL(value); const host = url.hostname.toLowerCase(); const path = url.pathname.replace(/\/+$/, ""); @@ -152,7 +152,6 @@ export const facebookUrlSchema = emptyOr( host === "m.facebook.com"; if (!isFacebookHost) return false; - if (path.startsWith("/share")) return false; if (path.startsWith("/sharer")) return false; @@ -162,15 +161,20 @@ export const facebookUrlSchema = emptyOr( } }, { - message: "Please enter a valid Facebook profile or page URL", + message: "Please enter a valid URL (e.g. https://www.example.com)", }, - ), -); + ); -export const instagramUrlSchema = emptyOr( - httpUrl.refine( +export const instagramUrlSchema = z + .string() + .transform(normalizeOptionalUrl) + .refine( (value) => { + if (value === null) return true; + try { + if (!hasHttpProtocol(value)) return false; + const url = new URL(value); const host = url.hostname.toLowerCase(); const path = url.pathname.replace(/\/+$/, ""); @@ -195,15 +199,20 @@ export const instagramUrlSchema = emptyOr( } }, { - message: "Please enter a valid Instagram profile URL", + message: "Please enter a valid URL (e.g. https://www.example.com)", }, - ), -); + ); -export const twitterUrlSchema = emptyOr( - httpUrl.refine( +export const twitterUrlSchema = z + .string() + .transform(normalizeOptionalUrl) + .refine( (value) => { + if (value === null) return true; + try { + if (!hasHttpProtocol(value)) return false; + const url = new URL(value); const host = url.hostname.toLowerCase(); const path = url.pathname.replace(/\/+$/, ""); @@ -215,7 +224,6 @@ export const twitterUrlSchema = emptyOr( host === "www.x.com"; if (!isTwitterHost) return false; - if (path.startsWith("/intent") || path.includes("/status/")) { return false; } @@ -226,10 +234,9 @@ export const twitterUrlSchema = emptyOr( } }, { - message: "Please enter a valid X/Twitter profile URL", + message: "Please enter a valid URL (e.g. https://www.example.com)", }, - ), -); + ); // NATION SCHEMA export const NationInsertSchema = createInsertSchema(orgs, {