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..1bb89ba6 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -98,16 +98,156 @@ export const CreateEventSchema = EventInsertSchema.omit({ }); export type EventInsertType = z.infer; +export const websiteUrlSchema = z + .string() + .trim() + .transform((v) => v || null) + .refine( + (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 a valid URL (e.g. https://www.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 = 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(/\/+$/, ""); + + 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 URL (e.g. https://www.example.com)", + }, + ); + +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(/\/+$/, ""); + + 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 URL (e.g. https://www.example.com)", + }, + ); + +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(/\/+$/, ""); + + 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 URL (e.g. https://www.example.com)", + }, + ); + // 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 +261,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 +277,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 +293,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 +309,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 +326,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);