diff --git a/apps/page/components/core/progress-bar.component.tsx b/apps/page/components/core/progress-bar.component.tsx new file mode 100644 index 0000000..bbb890e --- /dev/null +++ b/apps/page/components/core/progress-bar.component.tsx @@ -0,0 +1,18 @@ +import Router from "next/router"; +import NProgress from "nprogress"; +import "nprogress/nprogress.css"; + +NProgress.configure({ + minimum: 0.3, + easing: "ease", + speed: 500, + showSpinner: false, +}); + +Router.events.on("routeChangeStart", () => NProgress.start()); +Router.events.on("routeChangeComplete", () => NProgress.done()); +Router.events.on("routeChangeError", () => NProgress.done()); + +export default function ProgressBar() { + return null; +} \ No newline at end of file diff --git a/apps/page/components/page-header.tsx b/apps/page/components/page-header.tsx index 448fbfa..7c2e875 100644 --- a/apps/page/components/page-header.tsx +++ b/apps/page/components/page-header.tsx @@ -1,14 +1,22 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; +import { Menu } from "@headlessui/react"; +import { ChevronDownIcon } from "@heroicons/react/outline"; import classNames from "classnames"; import Image from "next/image"; +import Link from "next/link"; +import { PageRoadmap } from "../lib/data"; import OptionalLink from "./optional-link"; export default function PageHeader({ page, settings, + roadmaps = [], + isRoadmapPage = false, }: { page: IPage; settings: IPageSettings; + roadmaps?: PageRoadmap[]; + isRoadmapPage?: boolean; }) { return ( <> @@ -33,40 +41,120 @@ export default function PageHeader({
-
-
- - {settings?.page_logo ? ( - <> +
+
+
+ {settings?.page_logo && ( + {page?.title} -

+ + )} +
+ +

{page?.title}

- - ) : ( -

- {page?.title} -

- )} -
+ +
+

{page?.description && ( -

+

{page?.description}

)} + + {/* Navigation bar - only show if there are roadmaps */} + {roadmaps.length > 0 && ( +
+ +
+ )}
diff --git a/apps/page/components/subscribe-prompt.tsx b/apps/page/components/subscribe-prompt.tsx index 5f65375..9f2380f 100644 --- a/apps/page/components/subscribe-prompt.tsx +++ b/apps/page/components/subscribe-prompt.tsx @@ -1,6 +1,11 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; import { Spinner } from "@changes-page/ui"; -import { CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/solid"; +import { + BellIcon, + CheckCircleIcon, + InformationCircleIcon, + RssIcon, +} from "@heroicons/react/solid"; import classNames from "classnames"; import { useFormik } from "formik"; import { useMemo, useState } from "react"; @@ -17,6 +22,7 @@ export default function SubscribePrompt({ }) { const [loading, setLoading] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [showForm, setShowForm] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const pageUrl = useMemo(() => getPageUrl(page, settings), [page, settings]); @@ -48,7 +54,6 @@ export default function SubscribePrompt({ } setLoading(false); - // alert("Something went wrong. Please try again later."); console.error("/api/notification/email: error", e); } }, @@ -58,72 +63,70 @@ export default function SubscribePrompt({ return null; } - if (!settings?.email_notifications && settings) { + if (!settings?.email_notifications && settings?.rss_notifications) { return ( -
-
- -

- Get posts via - - {" "} - - RSS - {" "} - /{" "} - - Atom - {" "} - feed - -

+
+
); } return ( <> -
-
- -

- Subscribe to get future posts via email -

- {settings.rss_notifications ? ( -

- - {" "} - (or grab the{" "} - - RSS - {" "} - /{" "} - + {!showForm && !showSuccess ? ( +

) : null} {errorMessage && ( @@ -154,7 +157,46 @@ export default function SubscribePrompt({
)} - {showSuccess ? ( + {showForm && !showSuccess && ( +
+

+ Subscribe to get future posts via email +

+
+ + +
+ +
+
+
+ )} + + {showSuccess && (
- ) : ( -
- - - -
- -
-
)}
diff --git a/apps/page/hooks/usePageTheme.ts b/apps/page/hooks/usePageTheme.ts new file mode 100644 index 0000000..6f9e013 --- /dev/null +++ b/apps/page/hooks/usePageTheme.ts @@ -0,0 +1,12 @@ +import { useTheme } from "next-themes"; +import { useEffect } from "react"; + +export function usePageTheme(colorScheme?: string) { + const { setTheme } = useTheme(); + + useEffect(() => { + if (colorScheme && colorScheme !== "auto") { + setTheme(colorScheme); + } + }, [colorScheme, setTheme]); +} \ No newline at end of file diff --git a/apps/page/lib/data.ts b/apps/page/lib/data.ts index aac06fe..7686d4f 100644 --- a/apps/page/lib/data.ts +++ b/apps/page/lib/data.ts @@ -1,6 +1,13 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; import { Database } from "@changes-page/supabase/types"; -import { IPage, IPageSettings, IPost } from "@changes-page/supabase/types/page"; +import { + IPage, + IPageSettings, + IPost, + IRoadmapBoard, + IRoadmapColumn, + IRoadmapItem, +} from "@changes-page/supabase/types/page"; import { sanitizeCss } from "./css"; const PAGINATION_LIMIT = 50; @@ -70,6 +77,11 @@ export const BLACKLISTED_SLUGS = [ "press-kit", ]; +export type PageRoadmap = Pick< + IRoadmapBoard, + "id" | "title" | "slug" | "description" +>; + const postSelectParams = "id,title,content,tags,publication_date,updated_at,created_at,allow_reactions"; @@ -102,9 +114,11 @@ function translateHostToPageIdentifier(host: string): { }; } -async function fetchRenderData( - site: string -): Promise<{ page: IPage | null; settings: IPageSettings | null }> { +async function fetchRenderData(site: string): Promise<{ + page: IPage | null; + settings: IPageSettings | null; + roadmaps: PageRoadmap[]; +}> { const pageSelect = `id,title,description,type,url_slug,user_id`; const settingsSelect = `page_id,page_logo,cover_image,product_url,twitter_url,github_url,instagram_url,facebook_url,linkedin_url,youtube_url,tiktok_url,app_store_url,play_store_url,pinned_post_id,whitelabel,hide_search_engine,email_notifications,rss_notifications,color_scheme,custom_css`; @@ -114,6 +128,7 @@ async function fetchRenderData( const emptyResponse = { page: null, settings: null, + roadmaps: [], }; try { @@ -192,16 +207,30 @@ async function fetchRenderData( } } - return { - page: page as IPage, - settings: settings as IPageSettings, - }; + const [{ data: roadmaps }, isSubscriptionActive] = await Promise.all([ + supabaseAdmin + .from("roadmap_boards") + .select("id, title, slug, description") + .eq("page_id", page.id) + .eq("is_public", true) + .order("created_at", { ascending: true }), + isPageSubscriptionActive(page.user_id), + ]); + + return isSubscriptionActive + ? { + page: page as IPage, + settings: settings as IPageSettings, + roadmaps: roadmaps ?? [], + } + : emptyResponse; } catch (e) { console.log("[fetchRenderData] error", e); return { page: null, settings: null, + roadmaps: [], }; } } @@ -328,7 +357,7 @@ async function fetchPostById( }; } -async function isSubscriptionActive(user_id: string): Promise { +async function isPageSubscriptionActive(user_id: string): Promise { const { data: isSubscriptionActive, error } = await supabaseAdmin .rpc< "is_subscription_active", @@ -351,12 +380,72 @@ async function isSubscriptionActive(user_id: string): Promise { return isSubscriptionActive ?? true; } +export type RoadmapItemWithCategory = IRoadmapItem & { + roadmap_categories: { + id: string; + name: string; + color: string | null; + } | null; +}; + +async function getRoadmapBySlug( + pageId: string, + slug: string +): Promise<{ + board: IRoadmapBoard | null; + columns: IRoadmapColumn[]; + items: RoadmapItemWithCategory[] | null; +}> { + const { data: board, error } = await supabaseAdmin + .from("roadmap_boards") + .select("*") + .eq("page_id", pageId) + .eq("slug", slug) + .eq("is_public", true) + .single(); + + if (error) { + console.error("Error fetching roadmap by slug:", error); + return { board, columns: [], items: [] }; + } + + const { data: columns, error: columnsError } = await supabaseAdmin + .from("roadmap_columns") + .select("*") + .eq("board_id", board.id) + .order("position", { ascending: true }); + if (columnsError) { + console.error("Error fetching roadmap columns:", columnsError); + return { board, columns: [], items: [] }; + } + + const { data: items, error: itemsError } = await supabaseAdmin + .from("roadmap_items") + .select( + ` + *, + roadmap_categories ( + id, + name, + color + ) + ` + ) + .eq("board_id", board.id) + .order("position", { ascending: true }); + + if (itemsError) { + console.error("Failed to fetch items", itemsError); + } + + return { board, columns, items }; +} + export { fetchPostById, fetchPosts, fetchRenderData, - isSubscriptionActive, + getRoadmapBySlug, PAGINATION_LIMIT, - translateHostToPageIdentifier + translateHostToPageIdentifier, }; - diff --git a/apps/page/package.json b/apps/page/package.json index 81c4021..d50b49f 100644 --- a/apps/page/package.json +++ b/apps/page/package.json @@ -27,6 +27,7 @@ "next": "^14.2.25", "next-seo": "^5.15.0", "next-themes": "^0.2.1", + "nprogress": "^0.2.0", "postcss": "^8.4.33", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -49,6 +50,7 @@ "@next/bundle-analyzer": "^13.1.6", "@types/cors": "^2.8.13", "@types/node": "^17.0.41", + "@types/nprogress": "^0.2.3", "@types/react": "^18.2.0", "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^9.0.0", diff --git a/apps/page/pages/_app.tsx b/apps/page/pages/_app.tsx index 57e9f9c..8014983 100644 --- a/apps/page/pages/_app.tsx +++ b/apps/page/pages/_app.tsx @@ -1,8 +1,16 @@ import { ThemeProvider } from "next-themes"; import type { AppProps } from "next/app"; +import dynamic from "next/dynamic"; import Head from "next/head"; import "../styles/globals.css"; +const ProgressBar = dynamic( + () => import("../components/core/progress-bar.component"), + { + ssr: false, + } +); + function MyApp({ Component, pageProps }: AppProps) { return ( <> @@ -14,6 +22,7 @@ function MyApp({ Component, pageProps }: AppProps) { + ); diff --git a/apps/page/pages/_sites/[site]/index.tsx b/apps/page/pages/_sites/[site]/index.tsx index 2ed083f..10f3862 100644 --- a/apps/page/pages/_sites/[site]/index.tsx +++ b/apps/page/pages/_sites/[site]/index.tsx @@ -1,18 +1,18 @@ import { IPage, IPageSettings, IPost } from "@changes-page/supabase/types/page"; import { Timeline } from "@changes-page/ui"; import classNames from "classnames"; -import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import Footer from "../../../components/footer"; import PageHeader from "../../../components/page-header"; import Post from "../../../components/post"; import SeoTags from "../../../components/seo-tags"; import SubscribePrompt from "../../../components/subscribe-prompt"; +import { usePageTheme } from "../../../hooks/usePageTheme"; import { BLACKLISTED_SLUGS, fetchPosts, fetchRenderData, - isSubscriptionActive, + PageRoadmap, } from "../../../lib/data"; export default function Index({ @@ -20,20 +20,15 @@ export default function Index({ page, postsCount, settings, + roadmaps, }: { page: IPage; settings: IPageSettings; posts: IPost[]; postsCount: number; + roadmaps: PageRoadmap[]; }) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); const [posts, setPosts] = useState(initialPosts); const [loadingMore, setLoadingMore] = useState(false); @@ -71,7 +66,7 @@ export default function Index({
- + {(settings?.email_notifications || settings?.rss_notifications) && ( @@ -166,9 +161,9 @@ export async function getServerSideProps({ }; } - const { page, settings } = await fetchRenderData(site); + const { page, settings, roadmaps } = await fetchRenderData(site); - if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { + if (!page || !settings) { return { notFound: true, }; @@ -185,6 +180,7 @@ export async function getServerSideProps({ posts, postsCount, settings, + roadmaps, }, }; } diff --git a/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx b/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx index 7a84869..4e2a13f 100644 --- a/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx +++ b/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx @@ -1,8 +1,7 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; import { CheckCircleIcon } from "@heroicons/react/outline"; import type { GetServerSideProps } from "next"; -import { useTheme } from "next-themes"; -import { useEffect } from "react"; +import { usePageTheme } from "../../../../hooks/usePageTheme"; import PageHeader from "../../../../components/page-header"; import SeoTags from "../../../../components/seo-tags"; import { fetchRenderData } from "../../../../lib/data"; @@ -16,14 +15,7 @@ export default function Index({ page: IPage; settings: IPageSettings; }) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); return ( <> diff --git a/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx b/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx index 1a9b0bc..e2fad9e 100644 --- a/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx +++ b/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx @@ -2,20 +2,15 @@ import { Timeline } from "@changes-page/ui"; import { convertMarkdownToPlainText } from "@changes-page/utils"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid"; import { InferGetServerSidePropsType } from "next"; -import { useTheme } from "next-themes"; import Link from "next/link"; -import { useEffect } from "react"; import { validate as uuidValidate } from "uuid"; import Footer from "../../../../../components/footer"; import PageHeader from "../../../../../components/page-header"; import Post from "../../../../../components/post"; import SeoTags from "../../../../../components/seo-tags"; import SubscribePrompt from "../../../../../components/subscribe-prompt"; -import { - fetchPostById, - fetchRenderData, - isSubscriptionActive, -} from "../../../../../lib/data"; +import { usePageTheme } from "../../../../../hooks/usePageTheme"; +import { fetchPostById, fetchRenderData } from "../../../../../lib/data"; import { getPageUrl, getPostUrl } from "../../../../../lib/url"; export default function Index({ @@ -25,14 +20,7 @@ export default function Index({ settings, plainTextContent, }: InferGetServerSidePropsType) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); return ( <> @@ -120,8 +108,7 @@ export async function getServerSideProps({ } const { page, settings } = await fetchRenderData(site); - - if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { + if (!page || !settings) { return { notFound: true, }; diff --git a/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx new file mode 100644 index 0000000..a84f281 --- /dev/null +++ b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx @@ -0,0 +1,571 @@ +import { + IPage, + IPageSettings, + IRoadmapBoard, + IRoadmapColumn, +} from "@changes-page/supabase/types/page"; +import { getCategoryColorClasses } from "@changes-page/utils"; +import { Dialog, Transition } from "@headlessui/react"; +import { XIcon } from "@heroicons/react/outline"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import Footer from "../../../../components/footer"; +import PageHeader from "../../../../components/page-header"; +import SeoTags from "../../../../components/seo-tags"; +import { usePageTheme } from "../../../../hooks/usePageTheme"; +import { + BLACKLISTED_SLUGS, + fetchRenderData, + getRoadmapBySlug, + PageRoadmap, + RoadmapItemWithCategory, +} from "../../../../lib/data"; +import { getPageUrl } from "../../../../lib/url"; +import { httpPost } from "../../../../utils/http"; + +type RoadmapItem = RoadmapItemWithCategory & { + vote_count?: number; +}; + +export default function RoadmapPage({ + page, + settings, + board, + columns, + items, + roadmaps, +}: { + page: IPage; + settings: IPageSettings; + board: IRoadmapBoard; + columns: IRoadmapColumn[]; + items: RoadmapItem[]; + roadmaps: PageRoadmap[]; +}) { + usePageTheme(settings?.color_scheme); + + const [selectedItem, setSelectedItem] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [votes, setVotes] = useState< + Record + >({}); + const [votingItems, setVotingItems] = useState>(new Set()); + + const itemsByColumn = useMemo(() => { + const organized: Record = {}; + columns.forEach((column) => { + organized[column.id] = items + .filter((item) => item.column_id === column.id) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + }); + return organized; + }, [columns, items]); + + const openItemModal = (item: RoadmapItem) => { + setSelectedItem(item); + setIsModalOpen(true); + }; + + const closeItemModal = () => { + setIsModalOpen(false); + setSelectedItem(null); + }; + + const handleVote = async (itemId: string) => { + if (votingItems.has(itemId)) return; + + setVotingItems((prev) => new Set(prev).add(itemId)); + + const currentVoteState = votes[itemId]; + const newVotedState = !currentVoteState?.voted; + const newCount = currentVoteState?.count + ? newVotedState + ? currentVoteState.count + 1 + : currentVoteState.count - 1 + : newVotedState + ? 1 + : 0; + + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: newCount, + voted: newVotedState, + }, + })); + + try { + const data = await httpPost({ + url: "/api/roadmap/vote", + data: { item_id: itemId }, + }); + + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: data.vote_count || 0, + voted: newVotedState, + }, + })); + } catch (error) { + console.error("Error voting:", error); + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: currentVoteState?.count || 0, + voted: currentVoteState?.voted || false, + }, + })); + } finally { + setVotingItems((prev) => { + const newSet = new Set(prev); + newSet.delete(itemId); + return newSet; + }); + } + }; + + // Initialize vote data for items + useEffect(() => { + const fetchVotes = async () => { + if (items.length === 0) return; + + try { + const data = await httpPost({ + url: "/api/roadmap/votes", + data: { item_ids: items.map((item) => item.id) }, + }); + + // Transform API response to match expected frontend structure + const transformedVotes: Record< + string, + { count: number; voted: boolean } + > = {}; + Object.entries(data.votes).forEach( + ([itemId, voteData]: [string, any]) => { + transformedVotes[itemId] = { + count: voteData.vote_count, + voted: voteData.user_voted, + }; + } + ); + setVotes(transformedVotes); + } catch (error) { + console.error("Error fetching votes:", error); + } + }; + + fetchVotes(); + }, [items]); + + return ( + <> + + +
+ + + {/* Kanban Board Container */} +
+
+ {/* Roadmap Header */} +
+
+

+ {board.title} +

+ {board.description && ( +

+ {board.description} +

+ )} +
+
+ +
+
+
+ {columns.map((column) => ( +
+ {/* Column Header */} +
+
+

+ {column.name} +

+ + {itemsByColumn[column.id]?.length || 0} + +
+
+ + {/* Column Items */} +
+ {itemsByColumn[column.id]?.map((item) => ( +
openItemModal(item)} + className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer" + > +

+ {item.title} + {item.description && item.description.trim() && ( + + + + )} +

+ + {/* Bottom row with category and votes */} +
+
+ {item.roadmap_categories && ( + + {item.roadmap_categories.name} + + )} +
+ +
+
+ ))} + + {/* Empty state */} + {(!itemsByColumn[column.id] || + itemsByColumn[column.id].length === 0) && ( +
+ No items in this stage yet +
+ )} +
+
+ ))} +
+
+
+ + {/* Empty state for no columns */} + {columns.length === 0 && ( +
+
+

+ This roadmap is being set up +

+

Check back soon for updates!

+
+
+ )} +
+
+ +
+
+ + {/* Item Details Modal */} + + + +
+ + +
+
+ +
+ + +
+ {/* Column Divider */} +
+ + {/* Left side - Content */} +
+

+ {selectedItem?.title} +

+ {selectedItem?.description && ( +
+
+ + {selectedItem.description} + +
+
+ )} +
+ + {/* Right side - Metadata */} +
+ {/* Votes */} +
+ + Votes + + +
+ + {/* Status (Column) */} + {selectedItem?.column_id && ( +
+ + Status + + + {columns.find( + (col) => col.id === selectedItem.column_id + )?.name || "Unknown"} + +
+ )} + + {/* Category */} + {selectedItem?.roadmap_categories && ( +
+ + Category + + + {selectedItem.roadmap_categories.name} + +
+ )} + + {/* Board */} +
+ + Board + + + {board.title} + +
+ + {/* Created Date */} + {selectedItem?.created_at && ( +
+ + Created + + + {new Date( + selectedItem.created_at + ).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + +
+ )} +
+
+
+
+
+
+
+
+
+ + ); +} + +export async function getServerSideProps({ + params: { site, roadmap_slug }, +}: { + params: { site: string; roadmap_slug: string }; +}) { + console.log("handle roadmap ->", site, roadmap_slug); + + if (!site || !roadmap_slug) { + return { + notFound: true, + }; + } + + if (BLACKLISTED_SLUGS.includes(site)) { + return { + notFound: true, + }; + } + + const { page, settings, roadmaps } = await fetchRenderData(site); + + if (!page || !settings) { + return { + notFound: true, + }; + } + + const { board, columns, items } = await getRoadmapBySlug( + page.id, + roadmap_slug + ); + + if (!board) { + return { + notFound: true, + }; + } + + return { + props: { + page, + settings, + board, + columns: columns || [], + items: items || [], + roadmaps: roadmaps || [], + }, + }; +} diff --git a/apps/page/pages/api/pa/view.ts b/apps/page/pages/api/pa/view.ts index 23acd7e..499d9d8 100644 --- a/apps/page/pages/api/pa/view.ts +++ b/apps/page/pages/api/pa/view.ts @@ -26,7 +26,7 @@ async function pageAnalyticsView( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/post/react.ts b/apps/page/pages/api/post/react.ts index b985111..cc3e182 100644 --- a/apps/page/pages/api/post/react.ts +++ b/apps/page/pages/api/post/react.ts @@ -13,7 +13,7 @@ export default async function reactToPost( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/post/reactions.ts b/apps/page/pages/api/post/reactions.ts index fa2fa33..5305505 100644 --- a/apps/page/pages/api/post/reactions.ts +++ b/apps/page/pages/api/post/reactions.ts @@ -13,7 +13,7 @@ export default async function getPostReactions( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/roadmap/vote.ts b/apps/page/pages/api/roadmap/vote.ts new file mode 100644 index 0000000..48dfc67 --- /dev/null +++ b/apps/page/pages/api/roadmap/vote.ts @@ -0,0 +1,100 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; +import { supabaseAdmin } from "@changes-page/supabase/admin"; + +export default async function voteOnRoadmapItem( + req: NextApiRequest, + res: NextApiResponse<{ ok: boolean; vote_count?: number; error?: string }> +) { + if (req.method !== "POST") { + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } + + const { item_id } = req.body; + + if (!item_id) { + return res.status(400).json({ ok: false, error: "Missing item_id" }); + } + + let { cp_pa_vid: visitor_id } = req.cookies; + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Ensure item exists and belongs to a public board + const { data: itemCheck, error: itemCheckError } = await supabaseAdmin + .from("roadmap_items") + .select("id, board_id, roadmap_boards!inner(is_public)") + .eq("id", item_id) + .eq("roadmap_boards.is_public", true) + .maybeSingle(); + + if (itemCheckError || !itemCheck) { + return res + .status(404) + .json({ ok: false, error: "Item not found or not public" }); + } + + // Check if user has already voted + const { data: existingVote } = await supabaseAdmin + .from("roadmap_votes") + .select("*") + .eq("item_id", item_id) + .eq("visitor_id", visitor_id) + .maybeSingle(); + + if (existingVote) { + // Remove vote (toggle off) + const { error: deleteError } = await supabaseAdmin + .from("roadmap_votes") + .delete() + .eq("id", existingVote.id); + + if (deleteError) { + console.error("voteOnRoadmapItem [Delete Error]", deleteError); + return res + .status(500) + .json({ ok: false, error: "Failed to remove vote" }); + } + } else { + // Add vote + const { error: insertError } = await supabaseAdmin + .from("roadmap_votes") + .insert({ + id: v4(), + item_id: String(item_id), + visitor_id: visitor_id, + }); + + if (insertError) { + console.error("voteOnRoadmapItem [Insert Error]", insertError); + return res.status(500).json({ ok: false, error: "Failed to add vote" }); + } + } + + // Get updated vote count + const { count, error: countError } = await supabaseAdmin + .from("roadmap_votes") + .select("id", { count: "exact", head: true }) + .eq("item_id", item_id); + + if (countError) { + console.error("voteOnRoadmapItem [Count Error]", countError); + } + + res.status(200).json({ + ok: true, + vote_count: count || 0, + }); + } catch (e: Error | any) { + console.log("voteOnRoadmapItem [Error]", e); + res.status(500).json({ ok: false, error: "Internal server error" }); + } +} + diff --git a/apps/page/pages/api/roadmap/votes.ts b/apps/page/pages/api/roadmap/votes.ts new file mode 100644 index 0000000..db0ea21 --- /dev/null +++ b/apps/page/pages/api/roadmap/votes.ts @@ -0,0 +1,121 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; + +type BulkVotesResponse = { + ok: boolean; + votes: Record; +}; + +// UUID validation regex +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export default async function getBulkRoadmapItemVotes( + req: NextApiRequest, + res: NextApiResponse +) { + // Validate HTTP method + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ ok: false, votes: {} }); + } + + const { item_ids } = req.body; + let { cp_pa_vid: visitor_id } = req.cookies; + + // Input validation + if (!item_ids || !Array.isArray(item_ids)) { + return res.status(400).json({ ok: false, votes: {} }); + } + + // Prevent abuse with max array length + if (item_ids.length > 100) { + return res.status(400).json({ ok: false, votes: {} }); + } + + // Validate all item_ids are valid UUIDs + if (!item_ids.every((id) => typeof id === "string" && UUID_REGEX.test(id))) { + return res.status(400).json({ ok: false, votes: {} }); + } + + // De-duplicate to keep queries lean + const distinctItemIds: string[] = Array.from(new Set(item_ids)); + if (distinctItemIds.length === 0) { + return res.status(200).json({ ok: true, votes: {} }); + } + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Use a more efficient approach: get counts per item using a GROUP BY-like query + const voteCountPromises = distinctItemIds.map((itemId) => + supabaseAdmin + .from("roadmap_votes") + .select("id", { count: "exact", head: true }) + .eq("item_id", itemId) + ); + + const [userVoteResult, ...voteCountResults] = await Promise.all([ + supabaseAdmin + .from("roadmap_votes") + .select("item_id") + .in("item_id", distinctItemIds) + .eq("visitor_id", visitor_id), + ...voteCountPromises, + ]); + + if (userVoteResult.error) { + console.error( + "getBulkRoadmapItemVotes [User Error]", + userVoteResult.error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + + // Check for any errors in vote count queries + for (let i = 0; i < voteCountResults.length; i++) { + if (voteCountResults[i].error) { + console.error( + "getBulkRoadmapItemVotes [Count Error for %s]", + distinctItemIds[i], + voteCountResults[i].error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + } + + // Create vote counts map from the database counts + const voteCountsMap: Record = {}; + distinctItemIds.forEach((itemId, index) => { + voteCountsMap[itemId] = voteCountResults[index].count || 0; + }); + + const userVotedSet = new Set( + (userVoteResult.data || []).map((vote) => vote.item_id) + ); + + const votes: Record = + {}; + item_ids.forEach((itemId: string) => { + votes[itemId] = { + vote_count: voteCountsMap[itemId] || 0, + user_voted: userVotedSet.has(itemId), + }; + }); + + res.status(200).json({ + ok: true, + votes, + }); + } catch (e: Error | any) { + console.log("getBulkRoadmapItemVotes [Error]", e); + res.status(500).json({ ok: false, votes: {} }); + } +} diff --git a/apps/page/styles/globals.css b/apps/page/styles/globals.css index 034c088..6196cce 100644 --- a/apps/page/styles/globals.css +++ b/apps/page/styles/globals.css @@ -25,3 +25,17 @@ div#__next { -webkit-animation-iteration-count: 1; animation-iteration-count: 1; } + +#nprogress .bar { + background: #4f46e5 !important; + height: 4px !important; +} + +#nprogress .peg { + box-shadow: 0 0 10px #4f46e5, 0 0 5px #4f46e5; +} + +#nprogress .spinner-icon { + border-top-color: #4f46e5; + border-left-color: #4f46e5; +} \ No newline at end of file diff --git a/apps/page/tailwind.config.js b/apps/page/tailwind.config.js index d4536ba..a8d3da2 100644 --- a/apps/page/tailwind.config.js +++ b/apps/page/tailwind.config.js @@ -7,6 +7,17 @@ module.exports = { "./components/**/*.{js,ts,jsx,tsx}", "./node_modules/@changes-page/ui/components/**/*.{js,ts,jsx,tsx}", ], + safelist: [ + { + // emit base + dark variants for the used families and shades (incl. preview 500) + pattern: + /(bg|text|border)-(blue|indigo|purple|pink|red|orange|yellow|green|emerald|cyan)-(100|200|500|800|900)/, + variants: ["dark"], + }, + // Gray backgrounds/borders used globally + { pattern: /bg-gray-(800|900|950)/, variants: ["dark"] }, + { pattern: /border-gray-(700|800)/, variants: ["dark"] }, + ], theme: { extend: {}, }, diff --git a/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx b/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx index 55bbed4..4637c9c 100644 --- a/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx +++ b/apps/web/components/dialogs/ai-expand-concept-prompt-dialog.component.tsx @@ -72,6 +72,7 @@ export default function AiExpandConceptPromptDialogComponent({ }, [open, content]); return ( + // @ts-ignore
)} - + {/* @ts-ignore */}
{!editNotes ? ( formik.values.notes ? ( + // @ts-ignore {formik.values.notes} diff --git a/apps/web/components/layout/blog-layout.component.tsx b/apps/web/components/layout/blog-layout.component.tsx index 66a7cfb..dc95904 100644 --- a/apps/web/components/layout/blog-layout.component.tsx +++ b/apps/web/components/layout/blog-layout.component.tsx @@ -201,6 +201,7 @@ export default function BlogLayout({ ) : null}
+ {/* @ts-ignore */} 0 && "relative sm:pb-0 lg:pb-2" + !!title && tabs.length > 0 && "relative sm:pb-0" )} >
@@ -126,6 +126,7 @@ export default function Page({ - {!!title && tabs?.length > 0 && ( -
-
+ {!!title && tabs?.length > 0 ? ( +
+
@@ -156,7 +157,7 @@ export default function Page({ id="current-tab" name="current-tab" className="block w-full rounded-md dark:bg-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-700 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" - defaultValue={tabs.find((tab) => tab.current)?.name} + defaultValue={tabs.find((tab) => tab.current)?.href} onChange={(e) => router.push(e?.target?.value)} > {tabs.map((tab) => ( @@ -169,6 +170,7 @@ export default function Page({
- )} + ) : null}
diff --git a/apps/web/components/marketing/changelog.tsx b/apps/web/components/marketing/changelog.tsx index 19131f8..2501482 100644 --- a/apps/web/components/marketing/changelog.tsx +++ b/apps/web/components/marketing/changelog.tsx @@ -1,8 +1,8 @@ +import { IPost } from "@changes-page/supabase/types/page"; import { Transition } from "@headlessui/react"; +import { SpeakerphoneIcon } from "@heroicons/react/outline"; import { useRouter } from "next/router"; import { Fragment, useEffect, useState } from "react"; -import { IPost } from "@changes-page/supabase/types/page"; -import { SpeakerphoneIcon } from "@heroicons/react/outline"; type IPostWithUrl = IPost & { url: string }; @@ -44,6 +44,7 @@ export default function Changelog() { <> ( { return ( @@ -171,7 +172,7 @@ export function Post({ try { await supabase.from("posts").delete().eq("id", post.id); - await supabase.from("page_audit_logs").insert({ + await createAuditLog(supabase, { page_id: page.id, actor_id: user.id, action: "Deleted Post", @@ -310,6 +311,7 @@ export function Post({
+ {/* @ts-ignore */} { + const organized: ItemsByColumn = {}; + columns.forEach((column) => { + organized[column.id] = boardItems + .filter((item) => item.column_id === column.id) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + }); + return organized; + }, [columns, boardItems]); + + const dragDropHandlers = useRoadmapDragDrop({ + itemsByColumn, + setBoardItems, + board, + }); + + const itemHandlers = useRoadmapItems({ + board, + categories, + itemsByColumn, + }); + + return ( + <> +
+
+
+ {columns.map((column) => ( + + itemHandlers.handleDeleteItem(itemId, setBoardItems) + } + onDragStart={dragDropHandlers.handleDragStart} + onDragEnd={dragDropHandlers.handleDragEnd} + onDragOver={dragDropHandlers.handleDragOver} + onDragEnter={dragDropHandlers.handleDragEnter} + onDragLeave={dragDropHandlers.handleDragLeave} + onDrop={dragDropHandlers.handleDrop} + onItemDragOver={dragDropHandlers.handleItemDragOver} + draggedItem={dragDropHandlers.draggedItem} + dragOverColumn={dragDropHandlers.dragOverColumn} + dragOverPosition={dragDropHandlers.dragOverPosition} + /> + ))} +
+
+
+ + {columns.length === 0 && ( +
+ +

+ No stages configured +

+

+ This board doesn't have any stages set up yet. +

+
+ )} + + itemHandlers.handleSubmitItem(e, setBoardItems)} + itemForm={itemHandlers.itemForm} + setItemForm={itemHandlers.setItemForm} + formErrors={itemHandlers.formErrors} + isSubmitting={itemHandlers.isSubmitting} + editingItem={itemHandlers.editingItem} + categories={categories} + board={board} + /> + + ); +} diff --git a/apps/web/components/roadmap/RoadmapColumn.tsx b/apps/web/components/roadmap/RoadmapColumn.tsx new file mode 100644 index 0000000..be3f41a --- /dev/null +++ b/apps/web/components/roadmap/RoadmapColumn.tsx @@ -0,0 +1,149 @@ +import { IRoadmapColumn } from "@changes-page/supabase/types/page"; +import { PlusIcon } from "@heroicons/react/solid"; +import RoadmapItem from "./RoadmapItem"; +import { DragOverPosition, RoadmapItemWithRelations } from "./types"; + +interface RoadmapColumnProps { + column: IRoadmapColumn; + items: RoadmapItemWithRelations[]; + onAddItem: (columnId: string) => void; + onEditItem: (item: RoadmapItemWithRelations) => void; + onDeleteItem: (itemId: string) => void; + onDragStart: (e: React.DragEvent, item: RoadmapItemWithRelations) => void; + onDragEnd: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (e: React.DragEvent, columnId: string) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent, columnId: string) => void; + onItemDragOver: ( + e: React.DragEvent, + columnId: string, + itemId: string, + position: "before" | "after" + ) => void; + draggedItem: RoadmapItemWithRelations | null; + dragOverColumn: string | null; + dragOverPosition: DragOverPosition | null; +} + +export default function RoadmapColumn({ + column, + items, + onAddItem, + onEditItem, + onDeleteItem, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onItemDragOver, + draggedItem, + dragOverColumn, + dragOverPosition, +}: RoadmapColumnProps) { + return ( +
onDragEnter(e, column.id)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, column.id)} + > +
+
+

+ {column.name} + {dragOverColumn === column.id && ( + + + + + + )} +

+ + {items.length} + +
+
+ +
+ {items.map((item, itemIndex) => ( +
+
+ onItemDragOver(e, column.id, item.id, "before") + } + onDrop={(e) => onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "before" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + + + + {itemIndex === items.length - 1 && ( +
+ onItemDragOver(e, column.id, item.id, "after") + } + onDrop={(e) => onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "after" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} +
+ ))} + + {items.length > 0 && ( +
onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverColumn === column.id && !dragOverPosition + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} + + +
+
+ ); +} diff --git a/apps/web/components/roadmap/RoadmapItem.tsx b/apps/web/components/roadmap/RoadmapItem.tsx new file mode 100644 index 0000000..002ed7a --- /dev/null +++ b/apps/web/components/roadmap/RoadmapItem.tsx @@ -0,0 +1,102 @@ +import { getCategoryColorClasses } from "@changes-page/utils"; +import { PencilIcon, TrashIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; +import type React from "react"; +import { RoadmapItemWithRelations } from "./types"; + +interface RoadmapItemProps { + item: RoadmapItemWithRelations; + onEdit: (item: RoadmapItemWithRelations) => void; + onDelete: (itemId: string) => void; + onDragStart: (e: React.DragEvent, item: RoadmapItemWithRelations) => void; + onDragEnd: (e: React.DragEvent) => void; + isDragged: boolean; +} + +export default function RoadmapItem({ + item, + onEdit, + onDelete, + onDragStart, + onDragEnd, + isDragged, +}: RoadmapItemProps) { + return ( +
onDragStart(e, item)} + onDragEnd={onDragEnd} + className={classNames( + "bg-white dark:bg-gray-800 p-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-all group mb-2 cursor-move", + isDragged ? "opacity-50" : "" + )} + > +
+
+

+ {item.title} + {item.description && item.description.trim() && ( + + + + )} +

+
+
+ + +
+
+ +
+
+ {item.roadmap_categories && ( + + {item.roadmap_categories.name} + + )} +
+
+ + + + {item.roadmap_votes?.length || 0} +
+
+
+ ); +} diff --git a/apps/web/components/roadmap/RoadmapItemModal.tsx b/apps/web/components/roadmap/RoadmapItemModal.tsx new file mode 100644 index 0000000..1cabafa --- /dev/null +++ b/apps/web/components/roadmap/RoadmapItemModal.tsx @@ -0,0 +1,203 @@ +import { + IRoadmapBoard, + IRoadmapCategory, +} from "@changes-page/supabase/types/page"; +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment, useCallback } from "react"; +import MarkdownEditor from "../core/editor.component"; +import { FormErrors, ItemForm, RoadmapItemWithRelations } from "./types"; + +interface RoadmapItemModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; + itemForm: ItemForm; + setItemForm: React.Dispatch>; + formErrors: FormErrors; + isSubmitting: boolean; + editingItem: RoadmapItemWithRelations | null; + categories: IRoadmapCategory[]; + board: IRoadmapBoard; +} + +export default function RoadmapItemModal({ + isOpen, + onClose, + onSubmit, + itemForm, + setItemForm, + formErrors, + isSubmitting, + editingItem, + categories, + board, +}: RoadmapItemModalProps) { + const handleDescriptionChange = useCallback( + (value: string) => { + setItemForm((prev) => ({ + ...prev, + description: value, + })); + }, + [setItemForm] + ); + + return ( + // @ts-ignore + + + +
+ + +
+
+ +
+ + +
+ {formErrors.general && ( +
+
+ {formErrors.general} +
+
+ )} + + {formErrors.title && ( +
+
+ {formErrors.title} +
+
+ )} + +
+
+ +
+
+ + setItemForm((prev) => ({ + ...prev, + title: e.target.value, + })) + } + className="w-full text-xl font-semibold leading-6 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none mb-6" + placeholder="Enter item title..." + /> + +
+
+ +
+ {categories.length > 0 && ( +
+ + Category + + +
+ )} + +
+ + Board + + + {board.title} + +
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts b/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts new file mode 100644 index 0000000..6054a2e --- /dev/null +++ b/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts @@ -0,0 +1,313 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { useUserData } from "../../../utils/useUser"; +import { createAuditLog } from "../../../utils/auditLog"; +import { + DragOverPosition, + ItemsByColumn, + RoadmapItemWithRelations, +} from "../types"; +import { IRoadmapBoard } from "@changes-page/supabase/types/page"; + +export function useRoadmapDragDrop({ + itemsByColumn, + setBoardItems, + board, +}: { + itemsByColumn: ItemsByColumn; + setBoardItems: Dispatch>; + board: IRoadmapBoard; +}) { + const { supabase, user } = useUserData(); + const [draggedItem, setDraggedItem] = + useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [dragOverPosition, setDragOverPosition] = + useState(null); + + const handleDragStart = ( + e: React.DragEvent, + item: RoadmapItemWithRelations + ) => { + setDraggedItem(item); + e.dataTransfer.effectAllowed = "move"; + (e.target as HTMLElement).style.opacity = "0.5"; + }; + + const handleDragEnd = (e: React.DragEvent) => { + (e.target as HTMLElement).style.opacity = "1"; + setDraggedItem(null); + setDragOverColumn(null); + setDragOverPosition(null); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDragEnter = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverColumn(null); + setDragOverPosition(null); + } + }; + + const handleItemDragOver = ( + e: React.DragEvent, + columnId: string, + itemId: string, + position: "before" | "after" + ) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverColumn(columnId); + setDragOverPosition({ itemId, position }); + }; + + const handleDrop = async (e: React.DragEvent, targetColumnId: string) => { + e.preventDefault(); + setDragOverColumn(null); + const currentDragOverPosition = dragOverPosition; + setDragOverPosition(null); + + if (!draggedItem) return; + + try { + const sourceColumnId = draggedItem.column_id; + const sourceColumnItems = itemsByColumn[sourceColumnId] || []; + const targetColumnItems = itemsByColumn[targetColumnId] || []; + + if (sourceColumnId === targetColumnId) { + await handleSameColumnReorder( + sourceColumnItems, + currentDragOverPosition + ); + } else { + await handleCrossColumnMove( + targetColumnItems, + targetColumnId, + currentDragOverPosition + ); + } + + // Create audit log for item move + if (user && draggedItem) { + const action = sourceColumnId === targetColumnId ? "Reordered" : "Moved"; + const description = sourceColumnId === targetColumnId + ? `${action} item within column` + : `${action} item from column to column`; + + await createAuditLog(supabase, { + page_id: board.page_id, + actor_id: user.id, + action: `Updated Roadmap Item: ${draggedItem.title}`, + changes: { + action: description, + from_column: sourceColumnId, + to_column: targetColumnId, + item: draggedItem + }, + }); + } + } catch (error) { + console.error("Error moving item:", error); + alert("Failed to move item"); + } + + setDraggedItem(null); + }; + + const handleSameColumnReorder = async ( + sourceColumnItems: RoadmapItemWithRelations[], + currentDragOverPosition: DragOverPosition | null + ) => { + if (!currentDragOverPosition || !draggedItem) { + return; + } + + const draggedIndex = sourceColumnItems.findIndex( + (item) => item.id === draggedItem.id + ); + const targetIndex = sourceColumnItems.findIndex( + (item) => item.id === currentDragOverPosition.itemId + ); + + if ( + draggedIndex === -1 || + targetIndex === -1 || + draggedIndex === targetIndex + ) { + return; + } + + const reorderedItems = [...sourceColumnItems]; + const [draggedItemData] = reorderedItems.splice(draggedIndex, 1); + + let insertIndex = targetIndex; + if (currentDragOverPosition.position === "after") { + insertIndex = targetIndex + 1; + } + if ( + draggedIndex < targetIndex && + currentDragOverPosition.position === "before" + ) { + insertIndex = targetIndex - 1; + } + if ( + draggedIndex < targetIndex && + currentDragOverPosition.position === "after" + ) { + insertIndex = targetIndex; + } + + reorderedItems.splice(insertIndex, 0, draggedItemData); + + setBoardItems((prev) => { + return prev.map((item) => { + const updatedIndex = reorderedItems.findIndex( + (reorderedItem) => reorderedItem.id === item.id + ); + if (updatedIndex !== -1) { + return { ...item, position: updatedIndex + 1 }; + } + return item; + }); + }); + + const tempUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: 1000 + index }) + .eq("id", item.id) + ); + await Promise.all(tempUpdates); + + const finalUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: index + 1 }) + .eq("id", item.id) + ); + await Promise.all(finalUpdates); + }; + + const handleCrossColumnMove = async ( + targetColumnItems: RoadmapItemWithRelations[], + targetColumnId: string, + currentDragOverPosition: DragOverPosition | null + ) => { + if (!draggedItem) return; + + let newPosition = 1; + + if (!currentDragOverPosition) { + newPosition = + targetColumnItems.length > 0 + ? Math.max(...targetColumnItems.map((item) => item.position || 0)) + 1 + : 1; + } else { + const targetItem = targetColumnItems.find( + (item) => item.id === currentDragOverPosition.itemId + ); + if (targetItem) { + if (currentDragOverPosition.position === "before") { + newPosition = targetItem.position; + const itemsToShift = targetColumnItems.filter( + (item) => item.position >= targetItem.position + ); + if (itemsToShift.length > 0) { + // Sort items in descending position order to avoid uniqueness conflicts + const sortedItems = itemsToShift.sort( + (a, b) => (b.position || 0) - (a.position || 0) + ); + for (const item of sortedItems) { + await supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id); + } + } + } else { + newPosition = targetItem.position + 1; + const itemsToShift = targetColumnItems.filter( + (item) => item.position > targetItem.position + ); + if (itemsToShift.length > 0) { + // Sort items in descending position order to avoid uniqueness conflicts + const sortedItems = itemsToShift.sort( + (a, b) => (b.position || 0) - (a.position || 0) + ); + for (const item of sortedItems) { + await supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id); + } + } + } + } else { + newPosition = targetColumnItems.length + 1; + } + } + + const { error } = await supabase + .from("roadmap_items") + .update({ + column_id: targetColumnId, + position: newPosition, + }) + .eq("id", draggedItem.id); + + if (error) throw error; + + setBoardItems((prev) => + prev.map((item) => { + if (item.id === draggedItem.id) { + return { + ...item, + column_id: targetColumnId, + position: newPosition, + }; + } + if (item.column_id === targetColumnId && currentDragOverPosition) { + const targetItem = targetColumnItems.find( + (ti) => ti.id === currentDragOverPosition.itemId + ); + if (targetItem) { + if ( + currentDragOverPosition.position === "before" && + item.position >= targetItem.position + ) { + return { ...item, position: item.position + 1 }; + } + if ( + currentDragOverPosition.position === "after" && + item.position > targetItem.position + ) { + return { ...item, position: item.position + 1 }; + } + } + } + return item; + }) + ); + }; + + return { + draggedItem, + dragOverColumn, + dragOverPosition, + handleDragStart, + handleDragEnd, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleItemDragOver, + handleDrop, + }; +} diff --git a/apps/web/components/roadmap/hooks/useRoadmapItems.ts b/apps/web/components/roadmap/hooks/useRoadmapItems.ts new file mode 100644 index 0000000..0ff57ce --- /dev/null +++ b/apps/web/components/roadmap/hooks/useRoadmapItems.ts @@ -0,0 +1,233 @@ +import { + IRoadmapBoard, + IRoadmapCategory, +} from "@changes-page/supabase/types/page"; +import { useState } from "react"; +import { createAuditLog } from "../../../utils/auditLog"; +import { useUserData } from "../../../utils/useUser"; +import { + FormErrors, + ItemForm, + ItemsByColumn, + RoadmapItemWithRelations, +} from "../types"; + +export function useRoadmapItems({ + board, + categories, + itemsByColumn, +}: { + board: IRoadmapBoard; + categories: IRoadmapCategory[]; + itemsByColumn: ItemsByColumn; +}) { + const { supabase, user } = useUserData(); + const [showItemModal, setShowItemModal] = useState(false); + const [selectedColumnId, setSelectedColumnId] = useState(null); + const [editingItem, setEditingItem] = + useState(null); + const [itemForm, setItemForm] = useState({ + title: "", + description: "", + category_id: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formErrors, setFormErrors] = useState({}); + + const handleAddItem = (columnId: string) => { + setSelectedColumnId(columnId); + setEditingItem(null); + setItemForm({ + title: "", + description: "", + category_id: categories[0]?.id || "", + }); + setFormErrors({}); + setShowItemModal(true); + }; + + const handleEditItem = (item: RoadmapItemWithRelations) => { + setEditingItem(item); + setSelectedColumnId(item.column_id); + setItemForm({ + title: item.title, + description: item.description || "", + category_id: item.category_id || "", + }); + setFormErrors({}); + setShowItemModal(true); + }; + + const handleDeleteItem = async ( + itemId: string, + setBoardItems: React.Dispatch< + React.SetStateAction + > + ) => { + if (!confirm("Are you sure you want to delete this item?")) return; + + try { + const itemToDelete = Object.values(itemsByColumn) + .flat() + .find((it) => it.id === itemId); + + const { error } = await supabase + .from("roadmap_items") + .delete() + .eq("id", itemId); + + if (error) throw error; + + setBoardItems((prev) => prev.filter((item) => item.id !== itemId)); + + if (itemToDelete && user) { + await createAuditLog(supabase, { + page_id: board.page_id, + actor_id: user.id, + action: `Deleted Roadmap Item: ${itemToDelete.title}`, + changes: { item_id: itemToDelete.id, item_title: itemToDelete.title }, + }); + } + } catch (error) { + console.error("Error deleting item:", error); + alert("Failed to delete item"); + } + }; + + const validateForm = () => { + const errors: FormErrors = {}; + if (!itemForm.title.trim()) { + errors.title = "Title is required"; + } + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmitItem = async ( + e: React.FormEvent, + setBoardItems: React.Dispatch< + React.SetStateAction + > + ) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + try { + if (editingItem) { + const { data, error } = await supabase + .from("roadmap_items") + .update({ + title: itemForm.title.trim(), + description: itemForm.description.trim() || null, + category_id: itemForm.category_id || null, + }) + .eq("id", editingItem.id) + .select( + `*, + roadmap_categories ( + id, + name, + color + ), + roadmap_votes ( + id + )` + ) + .single(); + + if (error) throw error; + + setBoardItems((prev) => + prev.map((item) => (item.id === editingItem.id ? data : item)) + ); + + // Create audit log for update + if (user) { + await createAuditLog(supabase, { + page_id: board.page_id, + actor_id: user.id, + action: `Updated Roadmap Item: ${data.title}`, + changes: { + old: editingItem, + new: data, + }, + }); + } + } else { + if (!selectedColumnId) return; + + const columnItems = itemsByColumn[selectedColumnId] || []; + const maxPosition = + columnItems.length > 0 + ? Math.max(...columnItems.map((item) => item.position || 0)) + : 0; + + const { data, error } = await supabase + .from("roadmap_items") + .insert({ + board_id: board.id, + column_id: selectedColumnId, + title: itemForm.title.trim(), + description: itemForm.description.trim() || null, + category_id: itemForm.category_id || null, + position: maxPosition + 1, + }) + .select( + `*, + roadmap_categories ( + id, + name, + color + ), + roadmap_votes ( + id + )` + ) + .single(); + + if (error) throw error; + + setBoardItems((prev) => [...prev, data]); + + // Create audit log for creation + if (user) { + await createAuditLog(supabase, { + page_id: board.page_id, + actor_id: user.id, + action: `Created Roadmap Item: ${data.title}`, + changes: { item: data }, + }); + } + } + + setShowItemModal(false); + } catch (error) { + console.error("Error saving item:", error); + setFormErrors({ general: "Failed to save item" }); + } finally { + setIsSubmitting(false); + } + }; + + const closeModal = () => { + setShowItemModal(false); + setEditingItem(null); + setFormErrors({}); + }; + + return { + showItemModal, + selectedColumnId, + editingItem, + itemForm, + setItemForm, + isSubmitting, + formErrors, + handleAddItem, + handleEditItem, + handleDeleteItem, + handleSubmitItem, + closeModal, + }; +} diff --git a/apps/web/components/roadmap/types.ts b/apps/web/components/roadmap/types.ts new file mode 100644 index 0000000..9f94dcb --- /dev/null +++ b/apps/web/components/roadmap/types.ts @@ -0,0 +1,29 @@ +import { + IRoadmapCategory, + IRoadmapItem, + IRoadmapVote, +} from "@changes-page/supabase/types/page"; + +export interface RoadmapItemWithRelations extends IRoadmapItem { + roadmap_categories?: Pick; + roadmap_votes?: Pick[]; +} + +export interface ItemsByColumn { + [columnId: string]: RoadmapItemWithRelations[]; +} + +export interface ItemForm { + title: string; + description: string; + category_id: string; +} + +export interface DragOverPosition { + itemId: string; + position: "before" | "after"; +} + +export interface FormErrors { + [key: string]: string; +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 9424aa5..a01d504 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,20 +1,20 @@ -import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { createMiddlewareClient } from "./utils/supabase/middleware"; export async function middleware(req: NextRequest) { - // We need to create a response and hand it to the supabase client to be able to modify the response headers. - const res = NextResponse.next(); - const supabase = createMiddlewareClient({ req, res }); + const { supabase, response } = createMiddlewareClient(req); + + await supabase.auth.getSession(); + const { - data: { session }, - } = await supabase.auth.getSession(); + data: { user }, + } = await supabase.auth.getUser(); - if (session?.user) { - return res; + if (user) { + return response; } - // Auth condition not met, redirect to home page. const redirectUrl = req.nextUrl.clone(); redirectUrl.pathname = "/login"; redirectUrl.searchParams.set(`redirectedFrom`, req.nextUrl.pathname); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f624830..7ebed06 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -66,9 +66,6 @@ const moduleExports = { "cdn.sanity.io", ], }, - typescript: { - ignoreBuildErrors: true, - }, // PostHog rewrites for ingest and static assets async rewrites() { return [ diff --git a/apps/web/package.json b/apps/web/package.json index a9138ae..f670558 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,13 +21,13 @@ "@sanity/client": "^5.4.2", "@sanity/image-url": "^1.0.2", "@sentry/nextjs": "^7.77.0", - "@supabase/auth-helpers-nextjs": "^0.8.7", - "@supabase/auth-helpers-react": "^0.4.2", "@supabase/auth-ui-react": "^0.4.7", "@supabase/auth-ui-shared": "^0.1.8", + "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.39.3", "@tailwindcss/typography": "^0.5.1", "@types/canvas-confetti": "^1.6.4", + "@types/validator": "^13.15.3", "@vercel/og": "^0.0.20", "canvas-confetti": "^1.9.3", "chrono-node": "^2.7.6", @@ -67,6 +67,7 @@ "use-debounce": "^7.0.1", "use-file-picker": "^1.4.1", "uuid": "^8.3.2", + "validator": "^13.15.15", "yup": "^1.3.3" }, "devDependencies": { @@ -79,6 +80,6 @@ "postcss": "^8.4.5", "prettier": "^2.3.2", "tailwindcss": "^3.0.15", - "typescript": "^4" + "typescript": "^5" } -} +} \ No newline at end of file diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 5ce57f4..7116e1d 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -1,12 +1,10 @@ -import { createPagesBrowserClient } from "@supabase/auth-helpers-nextjs"; -import { SessionContextProvider } from "@supabase/auth-helpers-react"; import dynamic from "next/dynamic"; import localFont from "next/font/local"; import Head from "next/head"; import { Router } from "next/router"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import "../styles/global.css"; import { UserContextProvider } from "../utils/useUser"; @@ -32,7 +30,6 @@ const geist = localFont({ export default function App({ Component, pageProps }) { const getLayout = Component.getLayout || ((page) => page); - const [supabaseClient] = useState(() => createPagesBrowserClient()); useEffect(() => { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { @@ -63,15 +60,10 @@ export default function App({ Component, pageProps }) { --geist-font: ${geist.style.fontFamily}; } `} - - - {getLayout()} - - - + + {getLayout()} + + ); } diff --git a/apps/web/pages/api/ai/get-streaming-url.ts b/apps/web/pages/api/ai/get-streaming-url.ts index 8ce28c4..63b6948 100644 --- a/apps/web/pages/api/ai/get-streaming-url.ts +++ b/apps/web/pages/api/ai/get-streaming-url.ts @@ -1,17 +1,11 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; -import type { NextApiRequest, NextApiResponse } from "next"; import { createSignedStreamingUrl } from "../../../utils/manageprompt"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../utils/withAuth"; -const expandConcept = async ( - req: NextApiRequest, - res: NextApiResponse<{ url: string } | IErrorResponse> -) => { +const expandConcept = withAuth<{ url: string }>(async (req, res) => { if (req.method === "POST") { const { workflowId } = req.body; - try { - await getSupabaseServerClient({ req, res }); + try { const url = await createSignedStreamingUrl(workflowId); return res.status(200).json({ url }); @@ -25,6 +19,6 @@ const expandConcept = async ( res.setHeader("Allow", "POST"); res.status(405).end("Method Not Allowed"); } -}; +}); export default expandConcept; diff --git a/apps/web/pages/api/ai/suggest-title.ts b/apps/web/pages/api/ai/suggest-title.ts index 2919460..109132c 100644 --- a/apps/web/pages/api/ai/suggest-title.ts +++ b/apps/web/pages/api/ai/suggest-title.ts @@ -1,18 +1,11 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; -import type { NextApiRequest, NextApiResponse } from "next"; import { runWorkflow } from "../../../utils/manageprompt"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../utils/withAuth"; -const suggestTitle = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const suggestTitle = withAuth(async (req, res) => { if (req.method === "POST") { const { content } = req.body; try { - await getSupabaseServerClient({ req, res }); - const result = await runWorkflow("wf_e1eb79b1dc017ca189506d799453caae", { content, }); @@ -30,6 +23,6 @@ const suggestTitle = async ( res.setHeader("Allow", "POST"); res.status(405).end("Method Not Allowed"); } -}; +}); export default suggestTitle; diff --git a/apps/web/pages/api/auth/callback.ts b/apps/web/pages/api/auth/callback.ts index 1f9e6d3..ebb4091 100644 --- a/apps/web/pages/api/auth/callback.ts +++ b/apps/web/pages/api/auth/callback.ts @@ -1,18 +1,47 @@ -import { createPagesServerClient } from "@supabase/auth-helpers-nextjs"; import { NextApiHandler } from "next"; import { ROUTES } from "../../../data/routes.data"; -import { Database } from "@changes-page/supabase/types"; +import { createServerClientForAPI } from "../../../utils/supabase/server"; const callback: NextApiHandler = async (req, res) => { const code = req.query.code; const redirectedFrom = req.query.redirectedFrom; - if (typeof code === "string") { - const supabase = createPagesServerClient({ req, res }); - await supabase.auth.exchangeCodeForSession(code); + if (typeof code !== "string") { + return res.redirect( + `/login?error=${encodeURIComponent("Missing or invalid code")}` + ); } - res.redirect(redirectedFrom ? `${redirectedFrom}` : ROUTES.PAGES); + const supabase = createServerClientForAPI({ req, res }); + + try { + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + console.error("Auth callback error:", error); + return res.redirect(`/login?error=${encodeURIComponent(error.message)}`); + } + + if (!data.session) { + console.error("Auth callback: No session created"); + return res.redirect( + `/login?error=${encodeURIComponent("No session created")}` + ); + } + } catch (err) { + console.error("Auth callback exception:", err); + return res.redirect( + `/login?error=${encodeURIComponent("Authentication failed")}` + ); + } + + const redirectTo = + typeof redirectedFrom === "string" && + Object.values(ROUTES).includes(redirectedFrom) + ? redirectedFrom + : ROUTES.PAGES; + + res.redirect(redirectTo); }; export default callback; diff --git a/apps/web/pages/api/billing/create-billing-portal.ts b/apps/web/pages/api/billing/create-billing-portal.ts index 5a4ab92..2fdb8a1 100644 --- a/apps/web/pages/api/billing/create-billing-portal.ts +++ b/apps/web/pages/api/billing/create-billing-portal.ts @@ -1,45 +1,41 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; -import type { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createOrRetrieveCustomer } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; import { getAppBaseURL } from "./../../../utils/helpers"; const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); -const createBillingSession = async ( - req: NextApiRequest, - res: NextApiResponse<{ url: string } | IErrorResponse> -) => { - if (req.method === "POST") { - await apiRateLimiter(req, res); - const { return_url } = req.body; +const createBillingSession = withAuth<{ url: string }>( + async (req, res, { user }) => { + if (req.method === "POST") { + await apiRateLimiter(req, res); + const { return_url } = req.body; - try { - const { user } = await getSupabaseServerClient({ req, res }); - const customer = await createOrRetrieveCustomer(user.id, user.email); + try { + const customer = await createOrRetrieveCustomer(user.id, user.email); - console.log( - "createBillingSession", - user?.id, - "create billing session for existing user" - ); + console.log( + "createBillingSession", + user?.id, + "create billing session for existing user" + ); - const { url } = await stripe.billingPortal.sessions.create({ - customer, - return_url: return_url || `${getAppBaseURL()}/account/billing`, - }); + const { url } = await stripe.billingPortal.sessions.create({ + customer, + return_url: return_url || `${getAppBaseURL()}/account/billing`, + }); - return res.status(201).json({ url }); - } catch (err) { - console.log("createBillingSession", err); - res - .status(500) - .json({ error: { statusCode: 500, message: err.message } }); + return res.status(201).json({ url }); + } catch (err) { + console.log("createBillingSession", err); + res + .status(500) + .json({ error: { statusCode: 500, message: err.message } }); + } + } else { + res.setHeader("Allow", "POST"); + res.status(405).end("Method Not Allowed"); } - } else { - res.setHeader("Allow", "POST"); - res.status(405).end("Method Not Allowed"); } -}; +); export default createBillingSession; diff --git a/apps/web/pages/api/billing/enable-email-notifications.ts b/apps/web/pages/api/billing/enable-email-notifications.ts index 73a3316..d480b45 100644 --- a/apps/web/pages/api/billing/enable-email-notifications.ts +++ b/apps/web/pages/api/billing/enable-email-notifications.ts @@ -1,67 +1,88 @@ -import type { NextApiRequest, NextApiResponse } from "next"; import Stripe from "stripe"; -import { IErrorResponse } from "@changes-page/supabase/types/api"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { getUserById } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); -const enableEmailNotifications = async ( - req: NextApiRequest, - res: NextApiResponse<{ status: string } | IErrorResponse> -) => { - if (req.method === "PUT") { - try { - const { user } = await getSupabaseServerClient({ req, res }); +const enableEmailNotifications = withAuth<{ status: string }>( + async (req, res, { user }) => { + if (req.method === "PUT") { + try { + const priceId = process.env.EMAIL_NOTIFICATION_STRIPE_PRICE_ID; + if (!priceId) { + return res.status(500).json({ + error: { + statusCode: 500, + message: "Internal error: Price ID not configured.", + }, + }); + } - const { stripe_subscription_id, stripe_subscription } = await getUserById( - user.id - ); + const { stripe_subscription_id, stripe_subscription, pro_gifted } = + await getUserById(user.id); - if ( - !stripe_subscription || - (stripe_subscription as unknown as Stripe.Subscription)?.status === - "canceled" - ) { - return res.status(400).json({ - error: { - statusCode: 400, - message: - "You have canceled your subscription. Please reactivate it to continue using this feature.", - }, - }); - } + if (pro_gifted) { + return res.status(200).json({ status: "ok" }); + } - const subscription = await stripe.subscriptions.retrieve( - stripe_subscription_id - ); + if ( + !stripe_subscription || + (stripe_subscription as unknown as Stripe.Subscription)?.status === + "canceled" + ) { + return res.status(400).json({ + error: { + statusCode: 400, + message: + "You have canceled your subscription. Please reactivate it to continue using this feature.", + }, + }); + } - // Ignore if already added to subscription - if ( - subscription.items.data.find( - (item) => - item.price.id === process.env.EMAIL_NOTIFICATION_STRIPE_PRICE_ID - ) - ) { - return res.status(200).json({ status: "ok" }); - } + if (!stripe_subscription_id) { + return res.status(400).json({ + error: { + statusCode: 400, + message: "Missing Stripe subscription", + }, + }); + } + + const subscription = await stripe.subscriptions.retrieve( + stripe_subscription_id + ); - await stripe.subscriptionItems.create({ - subscription: stripe_subscription_id, - price: process.env.EMAIL_NOTIFICATION_STRIPE_PRICE_ID, - }); + // Ignore if already added to subscription + if ( + subscription.items.data.find( + (item: Stripe.SubscriptionItem) => item.price.id === priceId + ) + ) { + return res.status(200).json({ status: "ok" }); + } - return res.status(201).json({ status: "ok" }); - } catch (err) { - console.log("createBillingSession", err); - res - .status(500) - .json({ error: { statusCode: 500, message: err.message } }); + await stripe.subscriptionItems.create( + { + subscription: stripe_subscription_id, + price: priceId, + }, + { + idempotencyKey: `sub:${stripe_subscription_id}:price:${priceId}`, + } + ); + + return res.status(201).json({ status: "ok" }); + } catch (err) { + console.log("createBillingSession", err); + res + .status(500) + .json({ error: { statusCode: 500, message: err.message } }); + } + } else { + res.setHeader("Allow", "PUT"); + res.status(405).end("Method Not Allowed"); } - } else { - res.setHeader("Allow", "PUT"); - res.status(405).end("Method Not Allowed"); } -}; +); export default enableEmailNotifications; diff --git a/apps/web/pages/api/billing/index.ts b/apps/web/pages/api/billing/index.ts index ad97cb1..353d5ba 100644 --- a/apps/web/pages/api/billing/index.ts +++ b/apps/web/pages/api/billing/index.ts @@ -1,20 +1,13 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; -import type { NextApiRequest, NextApiResponse } from "next"; import Stripe from "stripe"; import { IBillingInfo } from "../../../data/user.interface"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { getUserById } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); -const getBillingStatus = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const getBillingStatus = withAuth(async (req, res, { user }) => { if (req.method === "GET") { try { - const { user } = await getSupabaseServerClient({ req, res }); - const { pro_gifted, stripe_customer_id, @@ -143,6 +136,6 @@ const getBillingStatus = async ( res.setHeader("Allow", "GET"); res.status(405).end("Method Not Allowed"); } -}; +}); export default getBillingStatus; diff --git a/apps/web/pages/api/billing/jobs/report-usage.ts b/apps/web/pages/api/billing/jobs/report-usage.ts index 0ba3f31..3a8fa99 100644 --- a/apps/web/pages/api/billing/jobs/report-usage.ts +++ b/apps/web/pages/api/billing/jobs/report-usage.ts @@ -5,6 +5,11 @@ import { Stripe } from "stripe"; import { v4 } from "uuid"; import { getPagesCount } from "../../../../utils/useDatabase"; +type UserWithSubscription = { + id: string; + stripe_subscription: Stripe.Subscription; +}; + const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); export const VALID_STRIPE_PRICE_IDS = [ @@ -15,7 +20,7 @@ export const VALID_STRIPE_PRICE_IDS = [ ]; const reportUsageJob = async ( - req: NextApiRequest, + _: NextApiRequest, res: NextApiResponse<{ status: string } | IErrorResponse> ) => { try { @@ -25,16 +30,16 @@ const reportUsageJob = async ( const { data: users, error } = await supabaseAdmin .from("users") - .select("id,stripe_subscription,stripe_subscription->>status") - .filter("stripe_subscription->>status", "in", '("active","trialing")'); + .select("id,stripe_subscription") + .filter("stripe_subscription->>status", "in", '("active","trialing")') + .overrideTypes(); if (error) throw error; console.log(`Job ${jobId} - Found ${users?.length} users`); for (const user of users ?? []) { - const subscription = - user.stripe_subscription as unknown as Stripe.Subscription; + const subscription = user.stripe_subscription; console.log( `Job ${jobId} - Processing user ${user.id} with status ${subscription.status}` diff --git a/apps/web/pages/api/billing/redirect-to-checkout.ts b/apps/web/pages/api/billing/redirect-to-checkout.ts index 5e63382..5fac6ae 100644 --- a/apps/web/pages/api/billing/redirect-to-checkout.ts +++ b/apps/web/pages/api/billing/redirect-to-checkout.ts @@ -1,25 +1,19 @@ -import type { NextApiRequest, NextApiResponse } from "next"; import { getAppBaseURL } from "../../../utils/helpers"; import { apiRateLimiter } from "../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createOrRetrieveCustomer, getUserById, } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); -const redirectToCheckout = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const redirectToCheckout = withAuth(async (req, res, { user }) => { if (req.method === "GET") { await apiRateLimiter(req, res); const { return_url } = req.query; try { - const { user } = await getSupabaseServerClient({ req, res }); - const { stripe_customer_id, stripe_subscription, @@ -44,7 +38,8 @@ const redirectToCheckout = async ( return_url: return_url || `${getAppBaseURL()}/pages`, }); - return res.redirect(307, url); + res.redirect(307, url); + return; } console.log( @@ -84,7 +79,8 @@ const redirectToCheckout = async ( cancel_url: return_url || getAppBaseURL(), }); - return res.redirect(307, url); + res.redirect(307, url); + return; } catch (err) { console.log("createCheckout", err); res @@ -95,6 +91,6 @@ const redirectToCheckout = async ( res.setHeader("Allow", "GET"); res.status(405).end("Method Not Allowed"); } -}; +}); export default redirectToCheckout; diff --git a/apps/web/pages/api/emails/subscribers/export-csv.ts b/apps/web/pages/api/emails/subscribers/export-csv.ts index e2db8d2..b265b7f 100644 --- a/apps/web/pages/api/emails/subscribers/export-csv.ts +++ b/apps/web/pages/api/emails/subscribers/export-csv.ts @@ -1,19 +1,14 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; import { Parser } from "@json2csv/plainjs"; -import { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../../utils/withAuth"; -const getSubscribersExportCsv = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const getSubscribersExportCsv = withAuth(async (req, res, { user }) => { if (req.method === "GET") { try { await apiRateLimiter(req, res); - const { user } = await getSupabaseServerClient({ req, res }); - const { page_id } = req.query; + const page_id = String(req.query.page_id); await supabaseAdmin .from("pages") @@ -54,6 +49,6 @@ const getSubscribersExportCsv = async ( res.setHeader("Allow", "POST,PUT"); res.status(405).end("Method Not Allowed"); } -}; +}); export default getSubscribersExportCsv; diff --git a/apps/web/pages/api/emails/subscribers/index.ts b/apps/web/pages/api/emails/subscribers/index.ts index 3fd9e52..499775b 100644 --- a/apps/web/pages/api/emails/subscribers/index.ts +++ b/apps/web/pages/api/emails/subscribers/index.ts @@ -1,45 +1,41 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; -import { NextApiRequest, NextApiResponse } from "next"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../../utils/withAuth"; -const getEmailSubscribers = async ( - req: NextApiRequest, - res: NextApiResponse -) => { - if (req.method === "GET") { - try { - const { user } = await getSupabaseServerClient({ req, res }); +const getEmailSubscribers = withAuth<{ count: number }>( + async (req, res, { user }) => { + if (req.method === "GET") { + try { + const page_id = String(req.query.page_id); - const { page_id } = req.query; + await supabaseAdmin + .from("pages") + .select("id") + .eq("id", page_id) + .eq("user_id", user.id) + .single(); - await supabaseAdmin - .from("pages") - .select("id") - .eq("id", page_id) - .eq("user_id", user.id) - .single(); + const { count, error } = await supabaseAdmin + .from("page_email_subscribers") + .select("page_id", { count: "exact" }) + .eq("page_id", page_id); - const { count, error } = await supabaseAdmin - .from("page_email_subscribers") - .select("page_id", { count: "exact" }) - .eq("page_id", page_id); + if (error) { + console.error(error); + throw new Error("Failed to get email subscribers"); + } - if (error) { - console.error(error); - throw new Error("Failed to get email subscribers"); + return res.status(200).json({ count }); + } catch (err) { + console.log("getEmailSubscribers: Error:", err); + res + .status(500) + .json({ error: { statusCode: 500, message: err.message } }); } - - return res.status(200).json({ count }); - } catch (err) { - console.log("getEmailSubscribers: Error:", err); - res - .status(500) - .json({ error: { statusCode: 500, message: err.message } }); + } else { + res.setHeader("Allow", "POST,PUT"); + res.status(405).end("Method Not Allowed"); } - } else { - res.setHeader("Allow", "POST,PUT"); - res.status(405).end("Method Not Allowed"); } -}; +); export default getEmailSubscribers; diff --git a/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx b/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx index 2d17308..bd23176 100644 --- a/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx +++ b/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx @@ -1,6 +1,6 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; import { IErrorResponse } from "@changes-page/supabase/types/api"; -import { IPost } from "@changes-page/supabase/types/page"; +import { IPost, PostStatus } from "@changes-page/supabase/types/page"; import type { NextApiRequest, NextApiResponse } from "next"; import { getPageUrl, getPostUrl } from "../../../../utils/hooks/usePageUrl"; import { @@ -40,7 +40,7 @@ export default async function handler( .from("posts") .select("id,title,content,tags,created_at") .eq("page_id", String(pageDetails.id)) - .eq("status", String(status)) + .eq("status", status as PostStatus) .order("created_at", { ascending: false }) .range(offset, limit - 1 + offset); diff --git a/apps/web/pages/api/pages/new.ts b/apps/web/pages/api/pages/new.ts index ee05531..7e47cc7 100644 --- a/apps/web/pages/api/pages/new.ts +++ b/apps/web/pages/api/pages/new.ts @@ -1,27 +1,20 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; import { IPage } from "@changes-page/supabase/types/page"; -import type { NextApiRequest, NextApiResponse } from "next"; import { NewPageSchema } from "../../../data/schema"; import { apiRateLimiter } from "../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createPage, getUserById, updateSubscriptionUsage, } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; -const createNewPage = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const createNewPage = withAuth(async (req, res, { user }) => { if (req.method === "POST") { await apiRateLimiter(req, res); const { url_slug, title, description, type } = req.body; try { - const { user } = await getSupabaseServerClient({ req, res }); - const { has_active_subscription } = await getUserById(user.id); if (!has_active_subscription) { return res.status(403).json({ @@ -65,6 +58,6 @@ const createNewPage = async ( res.setHeader("Allow", "POST"); res.status(405).end("Method Not Allowed"); } -}; +}); export default createNewPage; diff --git a/apps/web/pages/api/pages/reactions.ts b/apps/web/pages/api/pages/reactions.ts index 6ec582d..ad1b5e0 100644 --- a/apps/web/pages/api/pages/reactions.ts +++ b/apps/web/pages/api/pages/reactions.ts @@ -1,58 +1,46 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../utils/withAuth"; -export default async function getPostReactions( - req: NextApiRequest, - res: NextApiResponse<{ ok: boolean; aggregate: any }> -) { - let { post_id } = req.query; +export default withAuth<{ ok: boolean; aggregate: any }>( + async function getPostReactions(req, res) { + let { post_id } = req.query; - try { - const { user } = await getSupabaseServerClient({ req, res }); - if (!user) { - return res.status(401).json({ - ok: false, - aggregate: null, - }); - } + try { + const { data: aggregate, error: aggregateError } = + await supabaseAdmin.rpc("post_reactions_aggregate", { + postid: String(post_id), + }); - const { data: aggregate, error: aggregateError } = await supabaseAdmin.rpc( - "post_reactions_aggregate", - { - postid: String(post_id), + if (aggregateError) { + console.error("getPostReactions [Error]", aggregateError); } - ); - if (aggregateError) { - console.error("getPostReactions [Error]", aggregateError); - } + if (!aggregate?.length) { + res.status(200).json({ + ok: true, + aggregate: { + thumbs_up: 0, + thumbs_down: 0, + heart: 0, + sad: 0, + rocket: 0, + }, + }); + } - if (!aggregate?.length) { res.status(200).json({ ok: true, aggregate: { - thumbs_up: 0, - thumbs_down: 0, - heart: 0, - sad: 0, - rocket: 0, + thumbs_up: aggregate[0].thumbs_up_count, + thumbs_down: aggregate[0].thumbs_down_count, + heart: aggregate[0].heart_count, + sad: aggregate[0].sad_count, + rocket: aggregate[0].rocket_count, }, }); + } catch (e: Error | any) { + console.log("getPostReactions [Error]", e); + res.status(500).json({ ok: false, aggregate: null }); } - - res.status(200).json({ - ok: true, - aggregate: { - thumbs_up: aggregate[0].thumbs_up_count, - thumbs_down: aggregate[0].thumbs_down_count, - heart: aggregate[0].heart_count, - sad: aggregate[0].sad_count, - rocket: aggregate[0].rocket_count, - }, - }); - } catch (e: Error | any) { - console.log("getPostReactions [Error]", e); - res.status(500).json({ ok: false, aggregate: null }); } -} +); diff --git a/apps/web/pages/api/pages/settings/add-domain.ts b/apps/web/pages/api/pages/settings/add-domain.ts index c1b688c..a16bf5c 100644 --- a/apps/web/pages/api/pages/settings/add-domain.ts +++ b/apps/web/pages/api/pages/settings/add-domain.ts @@ -1,10 +1,8 @@ -import { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../../utils/withAuth"; -async function addDomain(req: NextApiRequest, res: NextApiResponse) { +const addDomain = withAuth(async (req, res, { user }) => { await apiRateLimiter(req, res); - const { user } = await getSupabaseServerClient({ req, res }); const { domain } = req.body; @@ -49,6 +47,6 @@ async function addDomain(req: NextApiRequest, res: NextApiResponse) { success: true, }); } -} +}); export default addDomain; diff --git a/apps/web/pages/api/pages/settings/check-domain.ts b/apps/web/pages/api/pages/settings/check-domain.ts index 18db62f..d0951dd 100644 --- a/apps/web/pages/api/pages/settings/check-domain.ts +++ b/apps/web/pages/api/pages/settings/check-domain.ts @@ -1,10 +1,8 @@ -import { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../../utils/withAuth"; -async function checkDomain(req: NextApiRequest, res: NextApiResponse) { +const checkDomain = withAuth<{ valid: boolean }>(async (req, res, { user }) => { await apiRateLimiter(req, res); - const { user } = await getSupabaseServerClient({ req, res }); const { domain } = req.query; @@ -34,6 +32,6 @@ async function checkDomain(req: NextApiRequest, res: NextApiResponse) { res.status(200).json({ valid, }); -} +}); export default checkDomain; diff --git a/apps/web/pages/api/pages/settings/index.ts b/apps/web/pages/api/pages/settings/index.ts index 72ba978..27e30b2 100644 --- a/apps/web/pages/api/pages/settings/index.ts +++ b/apps/web/pages/api/pages/settings/index.ts @@ -1,19 +1,12 @@ -import { IErrorResponse } from "@changes-page/supabase/types/api"; import { IPageSettings } from "@changes-page/supabase/types/page"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; import { createOrRetrievePageSettings } from "../../../../utils/useDatabase"; +import { withAuth } from "../../../../utils/withAuth"; -const getPageSettings = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const getPageSettings = withAuth(async (req, res, { user }) => { if (req.method === "GET") { const { page_id } = req.query; try { - const { user } = await getSupabaseServerClient({ req, res }); - console.log("getPageSettings", user?.id); const data = await createOrRetrievePageSettings(String(page_id)); @@ -29,6 +22,6 @@ const getPageSettings = async ( res.setHeader("Allow", "POST"); res.status(405).end("Method Not Allowed"); } -}; +}); export default getPageSettings; diff --git a/apps/web/pages/api/pages/settings/remove-domain.ts b/apps/web/pages/api/pages/settings/remove-domain.ts index 137d9cb..5e05738 100644 --- a/apps/web/pages/api/pages/settings/remove-domain.ts +++ b/apps/web/pages/api/pages/settings/remove-domain.ts @@ -1,28 +1,47 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import isFQDN from "validator/lib/isFQDN"; +import { withAuth } from "../../../../utils/withAuth"; -async function removeDomain(req: NextApiRequest, res: NextApiResponse) { - const { user } = await getSupabaseServerClient({ req, res }); +const removeDomain = withAuth<{ success: boolean }>( + async (req, res, { user }) => { + const { domain } = req.body; - const { domain } = req.body; + console.log("removeDomain", user?.id, `domain: ${domain}`); - console.log("removeDomain", user?.id, `domain: ${domain}`); + if ( + typeof domain !== "string" || + !isFQDN(domain, { require_tld: true, allow_underscores: false }) + ) { + return res.status(400).json({ success: false }); + } - const response = await fetch( - `https://api.vercel.com/v8/projects/${process.env.VERCEL_PAGES_PROJECT_ID}/domains/${domain}?teamId=${process.env.VERCEL_TEAM_ID}`, - { - headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, - }, - method: "DELETE", + // check custom domain ownership + const { data, error } = await supabaseAdmin + .from("page_settings") + .select("page_id,custom_domain, pages(user_id)") + .eq("custom_domain", domain) + .single(); + if (!data || error || data?.pages?.user_id !== user.id) { + return res.status(400).json({ success: false }); } - ); - await response.json(); + const safeDomain = data.custom_domain; + const response = await fetch( + `https://api.vercel.com/v8/projects/${process.env.VERCEL_PAGES_PROJECT_ID}/domains/${safeDomain}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { + Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + }, + method: "DELETE", + } + ); + + await response.json(); - res.status(200).json({ - success: true, - }); -} + res.status(200).json({ + success: true, + }); + } +); export default removeDomain; diff --git a/apps/web/pages/api/posts/index.ts b/apps/web/pages/api/posts/index.ts index 1c31ed0..5061045 100644 --- a/apps/web/pages/api/posts/index.ts +++ b/apps/web/pages/api/posts/index.ts @@ -1,11 +1,11 @@ import { PostStatus } from "@changes-page/supabase/types/page"; -import { NextApiRequest, NextApiResponse } from "next"; import { NewPostSchema } from "../../../data/schema"; import { apiRateLimiter } from "../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createPost } from "../../../utils/useDatabase"; +import { withAuth } from "../../../utils/withAuth"; +import { createAuditLog } from "../../../utils/auditLog"; -const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => { +const createNewPost = withAuth(async (req, res, { user, supabase }) => { if (req.method === "POST") { const { page_id, @@ -24,8 +24,6 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => { try { await apiRateLimiter(req, res); - const { user, supabase } = await getSupabaseServerClient({ req, res }); - const isValid = await NewPostSchema.isValid({ page_id, title: title.trim(), @@ -76,7 +74,7 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => { const post = await createPost(postPayload); - await supabase.from("page_audit_logs").insert({ + await createAuditLog(supabase, { page_id, actor_id: user.id, action: `Created Post: ${post.title}`, @@ -94,6 +92,6 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => { res.setHeader("Allow", "POST,PUT"); res.status(405).end("Method Not Allowed"); } -}; +}); export default createNewPost; diff --git a/apps/web/pages/api/teams/invite/accept/index.ts b/apps/web/pages/api/teams/invite/accept/index.ts index 0b435c5..b8941cd 100644 --- a/apps/web/pages/api/teams/invite/accept/index.ts +++ b/apps/web/pages/api/teams/invite/accept/index.ts @@ -1,9 +1,8 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; -import { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../../utils/supabase/supabase-admin"; +import { withAuth } from "../../../../../utils/withAuth"; -const acceptInvite = async (req: NextApiRequest, res: NextApiResponse) => { +const acceptInvite = withAuth(async (req, res, { user, supabase }) => { if (req.method === "POST") { const { invite_id } = req.body; if (!invite_id) { @@ -15,13 +14,6 @@ const acceptInvite = async (req: NextApiRequest, res: NextApiResponse) => { try { await apiRateLimiter(req, res); - const { user, supabase } = await getSupabaseServerClient({ req, res }); - if (!user) { - return res.status(401).json({ - error: { statusCode: 401, message: "Unauthorized" }, - }); - } - const { data: invite } = await supabase .from("team_invitations") .select("*") @@ -64,6 +56,6 @@ const acceptInvite = async (req: NextApiRequest, res: NextApiResponse) => { res.setHeader("Allow", "POST,PUT"); res.status(405).end("Method Not Allowed"); } -}; +}); export default acceptInvite; diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts index 1e4c52b..e66b923 100644 --- a/apps/web/pages/api/teams/invite/index.ts +++ b/apps/web/pages/api/teams/invite/index.ts @@ -1,98 +1,92 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; -import { NextApiRequest, NextApiResponse } from "next"; import { ROUTES } from "../../../../data/routes.data"; import { getAppBaseURL } from "../../../../utils/helpers"; import inngestClient from "../../../../utils/inngest"; import { apiRateLimiter } from "../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; import { getUserById } from "../../../../utils/useDatabase"; +import { withAuth } from "../../../../utils/withAuth"; -const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === "POST") { - const { team_id, email } = req.body; - if (!team_id || !email) { - return res.status(400).json({ - error: { statusCode: 400, message: "Invalid request" }, - }); - } - - try { - await apiRateLimiter(req, res); - - const { user, supabase } = await getSupabaseServerClient({ req, res }); - if (!user) { - return res.status(401).json({ - error: { statusCode: 401, message: "Unauthorized" }, +const inviteUser = withAuth<{ ok: boolean }>( + async (req, res, { user, supabase }) => { + if (req.method === "POST") { + const { team_id, email } = req.body; + if (!team_id || !email) { + return res.status(400).json({ + error: { statusCode: 400, message: "Invalid request" }, }); } - const { has_active_subscription } = await getUserById(user.id); - if (!has_active_subscription) { - return res.status(403).json({ - error: { statusCode: 403, message: "Missing subscription" }, - }); - } + try { + await apiRateLimiter(req, res); - if (user.email === email) { - return res.status(400).json({ - error: { statusCode: 400, message: "You cannot invite yourself" }, - }); - } + const { has_active_subscription } = await getUserById(user.id); + if (!has_active_subscription) { + return res.status(403).json({ + error: { statusCode: 403, message: "Missing subscription" }, + }); + } - const { data: team } = await supabase - .from("teams") - .select("*") - .eq("id", team_id) - .eq("owner_id", user.id) - .single(); + if (user.email === email) { + return res.status(400).json({ + error: { statusCode: 400, message: "You cannot invite yourself" }, + }); + } - if (!team) { - return res.status(403).json({ - error: { statusCode: 403, message: "Team not found" }, - }); - } + const { data: team } = await supabase + .from("teams") + .select("*") + .eq("id", team_id) + .eq("owner_id", user.id) + .single(); - const { data: invitation, error: invitationError } = await supabaseAdmin - .from("team_invitations") - .insert({ - team_id, - inviter_id: user.id, - email, - role: "editor", - status: "pending", - }) - .select() - .single(); + if (!team) { + return res.status(403).json({ + error: { statusCode: 403, message: "Team not found" }, + }); + } - if (!invitation) { - return res.status(500).json({ - error: { statusCode: 500, message: "Failed to create invitation" }, - }); - } + const { data: invitation, error: invitationError } = await supabaseAdmin + .from("team_invitations") + .insert({ + team_id, + inviter_id: user.id, + email, + role: "editor", + status: "pending", + }) + .select() + .single(); + + if (!invitation || invitationError) { + return res.status(500).json({ + error: { statusCode: 500, message: "Failed to create invitation" }, + }); + } - await inngestClient.send({ - name: "email/team.invite", - data: { - email, - payload: { - owner_name: user.user_metadata?.name ?? user.email, - team_name: team.name, - confirm_link: `${getAppBaseURL()}${ROUTES.TEAMS}`, + await inngestClient.send({ + name: "email/team.invite", + data: { + email, + payload: { + owner_name: user.user_metadata?.name ?? user.email, + team_name: team.name, + confirm_link: `${getAppBaseURL()}${ROUTES.TEAMS}`, + }, }, - }, - }); + }); - return res.status(201).json({ ok: true }); - } catch (err) { - console.log("inviteUser", err); - res - .status(500) - .json({ error: { statusCode: 500, message: err.message } }); + return res.status(201).json({ ok: true }); + } catch (err) { + console.log("inviteUser", err); + res + .status(500) + .json({ error: { statusCode: 500, message: err.message } }); + } + } else { + res.setHeader("Allow", "POST,PUT"); + res.status(405).end("Method Not Allowed"); } - } else { - res.setHeader("Allow", "POST,PUT"); - res.status(405).end("Method Not Allowed"); } -}; +); export default inviteUser; diff --git a/apps/web/pages/api/teams/member/[id]/index.ts b/apps/web/pages/api/teams/member/[id]/index.ts index 47091ac..7d04744 100644 --- a/apps/web/pages/api/teams/member/[id]/index.ts +++ b/apps/web/pages/api/teams/member/[id]/index.ts @@ -1,13 +1,9 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; -import { NextApiRequest, NextApiResponse } from "next"; import { apiRateLimiter } from "../../../../../utils/rate-limit"; -import { getSupabaseServerClient } from "../../../../../utils/supabase/supabase-admin"; import { getUserById } from "../../../../../utils/useDatabase"; +import { withAuth } from "../../../../../utils/withAuth"; -const getTeamMemberDetails = async ( - req: NextApiRequest, - res: NextApiResponse -) => { +const getTeamMemberDetails = withAuth(async (req, res, { user }) => { if (req.method === "GET") { const { id } = req.query; if (!id) { @@ -19,8 +15,6 @@ const getTeamMemberDetails = async ( try { await apiRateLimiter(req, res); - const { user } = await getSupabaseServerClient({ req, res }); - const { has_active_subscription } = await getUserById(user.id); if (!has_active_subscription) { return res.status(403).json({ @@ -31,7 +25,7 @@ const getTeamMemberDetails = async ( const { data: teamMember, error: teamMemberError } = await supabaseAdmin .from("team_members") .select("*") - .eq("id", id) + .eq("id", String(id)) .single(); if (teamMemberError) { @@ -62,6 +56,6 @@ const getTeamMemberDetails = async ( res.setHeader("Allow", "GET"); res.status(405).end("Method Not Allowed"); } -}; +}); export default getTeamMemberDetails; diff --git a/apps/web/pages/free-tools/release-calendar.tsx b/apps/web/pages/free-tools/release-calendar.tsx index 91a2a15..83c5a4d 100644 --- a/apps/web/pages/free-tools/release-calendar.tsx +++ b/apps/web/pages/free-tools/release-calendar.tsx @@ -1,9 +1,14 @@ -import { CalendarIcon, RefreshIcon, ViewGridIcon, ViewListIcon, PresentationChartLineIcon } from "@heroicons/react/solid"; +import { + CalendarIcon, + PresentationChartLineIcon, + RefreshIcon, + ViewGridIcon, + ViewListIcon, +} from "@heroicons/react/solid"; import classNames from "classnames"; import { InferGetServerSidePropsType } from "next"; import Head from "next/head"; -import Link from "next/link"; -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { createToastWrapper, notifyError, @@ -38,25 +43,25 @@ export default function ReleaseCalendar({ // Load data from localStorage on mount useEffect(() => { - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { try { - const savedReleases = localStorage.getItem('release-calendar-data'); + const savedReleases = localStorage.getItem("release-calendar-data"); if (savedReleases) { setReleases(JSON.parse(savedReleases)); } } catch (error) { - console.error('Failed to load releases from localStorage:', error); + console.error("Failed to load releases from localStorage:", error); } } }, []); // Save data to localStorage whenever releases change useEffect(() => { - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { try { - localStorage.setItem('release-calendar-data', JSON.stringify(releases)); + localStorage.setItem("release-calendar-data", JSON.stringify(releases)); } catch (error) { - console.error('Failed to save releases to localStorage:', error); + console.error("Failed to save releases to localStorage:", error); } } }, [releases]); @@ -64,7 +69,7 @@ export default function ReleaseCalendar({ const [showAddForm, setShowAddForm] = useState(false); const [editingRelease, setEditingRelease] = useState(null); const [presentationMode, setPresentationMode] = useState(false); - + // Form state const [formData, setFormData] = useState({ version: "", @@ -75,7 +80,6 @@ export default function ReleaseCalendar({ description: "", }); - // Generate unique ID for releases const generateId = useCallback(() => { return Date.now().toString(36) + Math.random().toString(36).substr(2); @@ -83,7 +87,11 @@ export default function ReleaseCalendar({ // Add or update release const handleSaveRelease = useCallback(() => { - if (!formData.version.trim() || !formData.date.trim() || !formData.title.trim()) { + if ( + !formData.version.trim() || + !formData.date.trim() || + !formData.title.trim() + ) { notifyError("Please fill in version, date, and title fields"); return; } @@ -99,10 +107,12 @@ export default function ReleaseCalendar({ }; if (editingRelease) { - setReleases(prev => prev.map(r => r.id === editingRelease.id ? releaseData : r)); + setReleases((prev) => + prev.map((r) => (r.id === editingRelease.id ? releaseData : r)) + ); notifySuccess("Release updated successfully"); } else { - setReleases(prev => [...prev, releaseData]); + setReleases((prev) => [...prev, releaseData]); notifySuccess("Release added successfully"); } @@ -135,7 +145,7 @@ export default function ReleaseCalendar({ // Delete release const handleDeleteRelease = useCallback((id: string) => { - setReleases(prev => prev.filter(r => r.id !== id)); + setReleases((prev) => prev.filter((r) => r.id !== id)); notifySuccess("Release deleted successfully"); }, []); @@ -154,15 +164,15 @@ export default function ReleaseCalendar({ setEditingRelease(null); setPresentationMode(false); // Clear localStorage - if (typeof window !== 'undefined') { - localStorage.removeItem('release-calendar-data'); + if (typeof window !== "undefined") { + localStorage.removeItem("release-calendar-data"); } notifySuccess("All data cleared"); }, []); // Toggle presentation mode const togglePresentationMode = useCallback(() => { - setPresentationMode(prev => !prev); + setPresentationMode((prev) => !prev); if (!presentationMode) { // Hide forms when entering presentation mode setShowAddForm(false); @@ -173,13 +183,13 @@ export default function ReleaseCalendar({ // Exit presentation mode with Escape key useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { - if (event.key === 'Escape' && presentationMode) { + if (event.key === "Escape" && presentationMode) { setPresentationMode(false); } }; - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); + document.addEventListener("keydown", handleKeyPress); + return () => document.removeEventListener("keydown", handleKeyPress); }, [presentationMode]); // Generate sample data @@ -189,88 +199,116 @@ export default function ReleaseCalendar({ { id: generateId(), version: "2.1.0", - date: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + date: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], title: "New Dashboard Features", type: "minor", status: "planned", - description: "Enhanced analytics dashboard with real-time metrics" + description: "Enhanced analytics dashboard with real-time metrics", }, { id: generateId(), version: "2.0.1", - date: new Date(today.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + date: new Date(today.getTime() - 5 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], title: "Bug Fixes", type: "patch", status: "released", - description: "Fixed authentication issues and improved performance" + description: "Fixed authentication issues and improved performance", }, { id: generateId(), version: "2.2.0", - date: new Date(today.getTime() + 21 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + date: new Date(today.getTime() + 21 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], title: "API v3 Launch", type: "major", status: "in-progress", - description: "Complete API overhaul with breaking changes" + description: "Complete API overhaul with breaking changes", }, { id: generateId(), version: "2.1.1", - date: new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + date: new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0], title: "Security Patch", type: "hotfix", status: "planned", - description: "Critical security vulnerability fix" - } + description: "Critical security vulnerability fix", + }, ]; setReleases(sampleReleases); notifySuccess("Sample data loaded"); }, [generateId]); - - // Sort releases by date - const sortedReleases = [...releases].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const sortedReleases = [...releases].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); // Get release type styles and emojis const getReleaseTypeStyles = (type: Release["type"]) => { switch (type) { - case "major": return "bg-red-600 text-white font-semibold"; - case "minor": return "bg-blue-600 text-white font-semibold"; - case "patch": return "bg-green-600 text-white font-semibold"; - case "hotfix": return "bg-orange-600 text-white font-semibold"; - default: return "bg-gray-600 text-white font-semibold"; + case "major": + return "bg-red-600 text-white font-semibold"; + case "minor": + return "bg-blue-600 text-white font-semibold"; + case "patch": + return "bg-green-600 text-white font-semibold"; + case "hotfix": + return "bg-orange-600 text-white font-semibold"; + default: + return "bg-gray-600 text-white font-semibold"; } }; const getReleaseTypeEmoji = (type: Release["type"]) => { switch (type) { - case "major": return "🚀"; - case "minor": return "✨"; - case "patch": return "🔧"; - case "hotfix": return "🚨"; - default: return "📦"; + case "major": + return "🚀"; + case "minor": + return "✨"; + case "patch": + return "🔧"; + case "hotfix": + return "🚨"; + default: + return "📦"; } }; // Get status styles and emojis const getStatusStyles = (status: Release["status"]) => { switch (status) { - case "planned": return "bg-gray-700 text-white border-gray-600"; - case "in-progress": return "bg-yellow-600 text-white border-yellow-500"; - case "released": return "bg-green-600 text-white border-green-500"; - case "delayed": return "bg-red-600 text-white border-red-500"; - default: return "bg-gray-700 text-white border-gray-600"; + case "planned": + return "bg-gray-700 text-white border-gray-600"; + case "in-progress": + return "bg-yellow-600 text-white border-yellow-500"; + case "released": + return "bg-green-600 text-white border-green-500"; + case "delayed": + return "bg-red-600 text-white border-red-500"; + default: + return "bg-gray-700 text-white border-gray-600"; } }; const getStatusEmoji = (status: Release["status"]) => { switch (status) { - case "planned": return "📅"; - case "in-progress": return "⚡"; - case "released": return "✅"; - case "delayed": return "⏰"; - default: return "📋"; + case "planned": + return "📅"; + case "in-progress": + return "⚡"; + case "released": + return "✅"; + case "delayed": + return "⏰"; + default: + return "📋"; } }; @@ -281,13 +319,18 @@ export default function ReleaseCalendar({ {title} {createToastWrapper(theme)} - + {/* Presentation Header */}
-

Release Timeline - Presentation Mode

-

Press ESC to exit • Use view toggle to switch between timeline and calendar

+

+ Release Timeline - Presentation Mode +

+

+ Press ESC to exit • Use view toggle to switch between timeline + and calendar +

{/* View Mode Toggle */} @@ -332,57 +375,77 @@ export default function ReleaseCalendar({ {releases.length === 0 ? (
-

No releases to present

-

Exit presentation mode to add releases

+

+ No releases to present +

+

+ Exit presentation mode to add releases +

) : viewMode === "timeline" ? (
-

Release Timeline

+

+ Release Timeline +

{/* Main timeline line */}
- +
{sortedReleases.map((release, index) => (
{/* Timeline dot */} -
- +
+ {/* Content */}
-
+
{/* Tape effect */}
- + {/* Shadow effect */}
- +
- - {getReleaseTypeEmoji(release.type)} + + + {getReleaseTypeEmoji(release.type)} + {release.type} - + {getStatusEmoji(release.status)} {release.status.replace("-", " ")} @@ -391,12 +454,15 @@ export default function ReleaseCalendar({ {release.version} - {release.title}

- {new Date(release.date).toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - })} + {new Date(release.date).toLocaleDateString( + "en-US", + { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + } + )}

{release.description && (

@@ -405,7 +471,7 @@ export default function ReleaseCalendar({ )}

- + {/* Sticky note lines */}
@@ -422,62 +488,94 @@ export default function ReleaseCalendar({ ) : ( // Calendar View
-

Release Calendar

+

+ Release Calendar +

{(() => { // Get current month or the month with releases const today = new Date(); - const releaseMonths = sortedReleases.length > 0 - ? [...new Set(sortedReleases.map(r => new Date(r.date).toISOString().slice(0, 7)))] - : [today.toISOString().slice(0, 7)]; - - return releaseMonths.map(monthStr => { - const [year, month] = monthStr.split('-').map(Number); + const releaseMonths = + sortedReleases.length > 0 + ? [ + ...Array.from( + new Set( + sortedReleases.map((r) => + new Date(r.date).toISOString().slice(0, 7) + ) + ) + ), + ] + : [today.toISOString().slice(0, 7)]; + + return releaseMonths.map((monthStr) => { + const [year, month] = monthStr.split("-").map(Number); const firstDay = new Date(year, month - 1, 1); - const lastDay = new Date(year, month, 0); const startDate = new Date(firstDay); startDate.setDate(startDate.getDate() - firstDay.getDay()); // Start from Sunday - - const monthReleases = sortedReleases.filter(r => - new Date(r.date).toISOString().slice(0, 7) === monthStr + + const monthReleases = sortedReleases.filter( + (r) => + new Date(r.date).toISOString().slice(0, 7) === monthStr ); - + // Generate calendar days const calendarDays = []; const currentDate = new Date(startDate); - - for (let i = 0; i < 42; i++) { // 6 weeks * 7 days - const dayReleases = monthReleases.filter(r => - new Date(r.date).toDateString() === currentDate.toDateString() + + for (let i = 0; i < 42; i++) { + // 6 weeks * 7 days + const dayReleases = monthReleases.filter( + (r) => + new Date(r.date).toDateString() === + currentDate.toDateString() ); - + calendarDays.push({ date: new Date(currentDate), isCurrentMonth: currentDate.getMonth() === month - 1, - isToday: currentDate.toDateString() === today.toDateString(), - releases: dayReleases + isToday: + currentDate.toDateString() === today.toDateString(), + releases: dayReleases, }); - + currentDate.setDate(currentDate.getDate() + 1); } - + return ( -
+
{/* Calendar Header */}

- {firstDay.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + {firstDay.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })}

- + {/* Days of Week Header */}
- {['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(day => ( -
+ {[ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ].map((day) => ( +
{day}
))}
- + {/* Calendar Grid */}
{calendarDays.map((day, index) => ( @@ -485,20 +583,26 @@ export default function ReleaseCalendar({ key={index} className={classNames( "min-h-[120px] p-3 border border-white/10 rounded-lg relative", - day.isCurrentMonth ? "bg-white/5" : "bg-gray-800/50", + day.isCurrentMonth + ? "bg-white/5" + : "bg-gray-800/50", day.isToday ? "ring-2 ring-indigo-500" : "", day.releases.length > 0 ? "bg-indigo-900/20" : "" )} > {/* Date Number */} -
+
{day.date.getDate()}
- + {/* Releases for this day */}
{day.releases.slice(0, 3).map((release, idx) => ( @@ -506,18 +610,34 @@ export default function ReleaseCalendar({ key={release.id} className={classNames( "text-sm px-2 py-1 rounded cursor-pointer transition-all hover:scale-105 flex items-center gap-2 font-medium", - release.type === "major" ? "bg-red-600 text-white" : - release.type === "minor" ? "bg-blue-600 text-white" : - release.type === "patch" ? "bg-green-600 text-white" : - release.type === "hotfix" ? "bg-orange-600 text-white" : - "bg-gray-600 text-white" + release.type === "major" + ? "bg-red-600 text-white" + : release.type === "minor" + ? "bg-blue-600 text-white" + : release.type === "patch" + ? "bg-green-600 text-white" + : release.type === "hotfix" + ? "bg-orange-600 text-white" + : "bg-gray-600 text-white" )} - title={`${release.version} - ${release.title}${release.description ? '\n' + release.description : ''}`} + title={`${release.version} - ${ + release.title + }${ + release.description + ? "\n" + release.description + : "" + }`} > - {getReleaseTypeEmoji(release.type)} + + {getReleaseTypeEmoji(release.type)} +
-
{release.version}
-
{release.title}
+
+ {release.version} +
+
+ {release.title} +
))} @@ -530,20 +650,29 @@ export default function ReleaseCalendar({
))}
- + {/* Month Legend */} {monthReleases.length > 0 && (
-

Releases this month:

+

+ Releases this month: +

- {monthReleases.map(release => ( -
+ {monthReleases.map((release) => ( +
- - {getReleaseTypeEmoji(release.type)} + + + {getReleaseTypeEmoji(release.type)} + {release.type}
@@ -551,9 +680,11 @@ export default function ReleaseCalendar({ {release.version} - {release.title}
- {new Date(release.date).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric' + {new Date( + release.date + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", })}
{release.description && ( @@ -586,25 +717,25 @@ export default function ReleaseCalendar({ - + {/* Open Graph Meta Tags */} - + {/* Twitter Card Meta Tags */} - + {/* Additional SEO Meta Tags */} - + {/* Structured Data */}