diff --git a/apps/page/components/roadmap/TriageSubmissionModal.tsx b/apps/page/components/roadmap/TriageSubmissionModal.tsx new file mode 100644 index 0000000..6f03df9 --- /dev/null +++ b/apps/page/components/roadmap/TriageSubmissionModal.tsx @@ -0,0 +1,148 @@ +import { Dialog } from "@headlessui/react"; +import { XIcon } from "@heroicons/react/outline"; +import { useState } from "react"; +import { httpPost } from "../../utils/http"; + +interface TriageSubmissionModalProps { + isOpen: boolean; + onClose: () => void; + boardId: string; + onSuccess?: () => void; +} + +export default function TriageSubmissionModal({ + isOpen, + onClose, + boardId, + onSuccess, +}: TriageSubmissionModalProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + + try { + await httpPost({ + url: "/api/roadmap/submit-triage", + data: { + board_id: boardId, + title: title.trim(), + description: description.trim() || null, + }, + }); + + setTitle(""); + setDescription(""); + onSuccess?.(); + onClose(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Something went wrong. Please try again." + ); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + if (isLoading) return; + setTitle(""); + setDescription(""); + setError(""); + onClose(); + }; + + return ( + + + + + + + + Submit Your Idea + + + + + + + + + + Title * + + setTitle(e.target.value)} + required + maxLength={200} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100" + placeholder="Brief description of your idea" + /> + + + + + Description (optional) + + setDescription(e.target.value)} + rows={5} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100" + placeholder="Provide more details about your idea..." + /> + + + {error && ( + + {error} + + )} + + + + Cancel + + + {isLoading ? "Submitting..." : "Submit Idea"} + + + + + + + ); +} diff --git a/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx index a942b62..4b6cc32 100644 --- a/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx +++ b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx @@ -16,6 +16,7 @@ import Footer from "../../../../components/footer"; import PageHeader from "../../../../components/page-header"; import SeoTags from "../../../../components/seo-tags"; import VisitorAuthModal from "../../../../components/visitor-auth-modal"; +import TriageSubmissionModal from "../../../../components/roadmap/TriageSubmissionModal"; import { usePageTheme } from "../../../../hooks/usePageTheme"; import { useVisitorAuth } from "../../../../hooks/useVisitorAuth"; import { @@ -53,6 +54,7 @@ export default function RoadmapPage({ const [selectedItem, setSelectedItem] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const [isTriageModalOpen, setIsTriageModalOpen] = useState(false); const [votes, setVotes] = useState< Record >({}); @@ -78,6 +80,18 @@ export default function RoadmapPage({ setSelectedItem(null); }; + const handleContributeClick = () => { + if (!visitor) { + setIsAuthModalOpen(true); + return; + } + setIsTriageModalOpen(true); + }; + + const handleTriageSuccess = () => { + alert("Thank you for your contribution! We'll review your idea soon."); + }; + const handleVote = async (itemId: string) => { if (!visitor) { setIsAuthModalOpen(true); @@ -194,14 +208,24 @@ export default function RoadmapPage({ {/* Roadmap Header */} - - {board.title} - - {board.description && ( - - {board.description} - - )} + + + + {board.title} + + {board.description && ( + + {board.description} + + )} + + + Contribute Idea + + @@ -546,6 +570,13 @@ export default function RoadmapPage({ isOpen={isAuthModalOpen} onClose={() => setIsAuthModalOpen(false)} /> + + setIsTriageModalOpen(false)} + boardId={board.id} + onSuccess={handleTriageSuccess} + /> > ); } diff --git a/apps/page/pages/api/json.ts b/apps/page/pages/api/json.ts index c318ff1..3625d0c 100644 --- a/apps/page/pages/api/json.ts +++ b/apps/page/pages/api/json.ts @@ -42,7 +42,7 @@ async function handler( ); res.status(200).json(postsWithUrl); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Failed to fetch posts [Error]", e); res.status(200).json([]); } diff --git a/apps/page/pages/api/latest.ts b/apps/page/pages/api/latest.ts index 2fc2111..df5d3fe 100644 --- a/apps/page/pages/api/latest.ts +++ b/apps/page/pages/api/latest.ts @@ -58,7 +58,7 @@ async function handler( }); res.status(200).json(postsWithUrl[0] ?? null); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Failed to fetch latest post [Error]", e); res.status(404).json(null); } diff --git a/apps/page/pages/api/markdown.ts b/apps/page/pages/api/markdown.ts index 9a75b7a..d1e65ef 100644 --- a/apps/page/pages/api/markdown.ts +++ b/apps/page/pages/api/markdown.ts @@ -39,7 +39,7 @@ ${post.content} }); res.status(200).send(markdown); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Failed to fetch posts [changes.md] [Error]", e); res.status(200).send("## No posts Found"); } diff --git a/apps/page/pages/api/notifications/subscribe-email.ts b/apps/page/pages/api/notifications/subscribe-email.ts index 330e9c1..496f220 100644 --- a/apps/page/pages/api/notifications/subscribe-email.ts +++ b/apps/page/pages/api/notifications/subscribe-email.ts @@ -102,9 +102,12 @@ async function handler( }); res.status(200).json({ success: true }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("notifications/email: [Error]", e); - res.status(400).json({ success: false, message: e.message ?? String(e) }); + res.status(400).json({ + success: false, + message: e instanceof Error ? e.message : String(e), + }); } } else { res.setHeader("Allow", "POST"); diff --git a/apps/page/pages/api/pa/view.ts b/apps/page/pages/api/pa/view.ts index c7aedec..815ec08 100644 --- a/apps/page/pages/api/pa/view.ts +++ b/apps/page/pages/api/pa/view.ts @@ -52,7 +52,7 @@ async function pageAnalyticsView( } res.status(200).json({ success: true }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("pageAnalyticsView [Error]", e); res.status(200).json({ success: true }); } diff --git a/apps/page/pages/api/pinned.ts b/apps/page/pages/api/pinned.ts index 08aca95..7a4af86 100644 --- a/apps/page/pages/api/pinned.ts +++ b/apps/page/pages/api/pinned.ts @@ -59,7 +59,7 @@ async function handler( }); res.status(200).json(postsWithUrl[0]); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Failed to fetch pinned post [Error]", e); res.status(404).json(null); } diff --git a/apps/page/pages/api/post/[id].ts b/apps/page/pages/api/post/[id].ts index de7681b..d5cb884 100644 --- a/apps/page/pages/api/post/[id].ts +++ b/apps/page/pages/api/post/[id].ts @@ -67,7 +67,7 @@ async function handler( url: getPostUrl(pageUrl, post), plain_text_content: convertMarkdownToPlainText(post.content), }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Failed to fetch post [Error]", e); res.status(404).json(null); } diff --git a/apps/page/pages/api/post/react.ts b/apps/page/pages/api/post/react.ts index 924a41d..06d8a1c 100644 --- a/apps/page/pages/api/post/react.ts +++ b/apps/page/pages/api/post/react.ts @@ -41,7 +41,7 @@ export default async function reactToPost( } res.status(200).json({ success: true }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("reactToPost [Error]", e); res.status(200).json({ success: false }); } diff --git a/apps/page/pages/api/post/reactions.ts b/apps/page/pages/api/post/reactions.ts index 5f13eec..4b3471b 100644 --- a/apps/page/pages/api/post/reactions.ts +++ b/apps/page/pages/api/post/reactions.ts @@ -53,7 +53,7 @@ export default async function getPostReactions( : null, user, }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("getPostReactions [Error]", e); res.status(200).json({ success: false, aggregate: null, user: null }); } diff --git a/apps/page/pages/api/posts.ts b/apps/page/pages/api/posts.ts index 73ee33c..8471460 100644 --- a/apps/page/pages/api/posts.ts +++ b/apps/page/pages/api/posts.ts @@ -28,8 +28,13 @@ async function handler( .order("publication_date", { ascending: false }); res.status(200).json(posts as Array); - } catch (e: Error | any) { - res.status(500).json({ error: { statusCode: 500, message: e.message } }); + } catch (e: unknown) { + res.status(500).json({ + error: { + statusCode: 500, + message: e instanceof Error ? e.message : String(e), + }, + }); } } diff --git a/apps/page/pages/api/roadmap/submit-triage.ts b/apps/page/pages/api/roadmap/submit-triage.ts new file mode 100644 index 0000000..5065652 --- /dev/null +++ b/apps/page/pages/api/roadmap/submit-triage.ts @@ -0,0 +1,98 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; +import { getAuthenticatedVisitor } from "../../../lib/visitor-auth"; + +export default async function submitTriageItem( + req: NextApiRequest, + res: NextApiResponse<{ success: boolean; item?: any; error?: string }> +) { + if (req.method !== "POST") { + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); + } + + const { board_id, title, description } = req.body; + + if (!board_id) { + return res.status(400).json({ success: false, error: "Missing board_id" }); + } + + if (!title || typeof title !== "string") { + return res.status(400).json({ success: false, error: "Title is required" }); + } + + const trimmedTitle = title.trim(); + const trimmedDescription = + description && typeof description === "string" ? description.trim() : null; + + if (!trimmedTitle) { + return res + .status(400) + .json({ success: false, error: "Title cannot be empty" }); + } + + if (trimmedTitle.length > 200) { + return res + .status(400) + .json({ success: false, error: "Title must be 200 characters or less" }); + } + + if (trimmedDescription && trimmedDescription.length > 2000) { + return res.status(400).json({ + success: false, + error: "Description must be 2000 characters or less", + }); + } + + const visitor = await getAuthenticatedVisitor(req); + + if (!visitor) { + return res + .status(401) + .json({ success: false, error: "Authentication required" }); + } + + try { + const { data: boardCheck, error: boardCheckError } = await supabaseAdmin + .from("roadmap_boards") + .select("id, is_public") + .eq("id", board_id) + .eq("is_public", true) + .maybeSingle(); + + if (boardCheckError || !boardCheck) { + return res + .status(404) + .json({ success: false, error: "Board not found or not public" }); + } + + const { data: triageItem, error: insertError } = await supabaseAdmin + .from("roadmap_triage_items") + .insert({ + id: v4(), + board_id: String(board_id), + title: trimmedTitle, + description: trimmedDescription, + visitor_id: visitor.id, + }) + .select() + .single(); + + if (insertError) { + console.error("submitTriageItem [Insert Error]", insertError); + return res + .status(500) + .json({ success: false, error: "Failed to submit item" }); + } + + res.status(200).json({ + success: true, + item: triageItem, + }); + } catch (e: unknown) { + console.log("submitTriageItem [Error]", e); + res.status(500).json({ success: false, error: "Internal server error" }); + } +} diff --git a/apps/page/pages/api/roadmap/vote.ts b/apps/page/pages/api/roadmap/vote.ts index ad783d4..09c70d0 100644 --- a/apps/page/pages/api/roadmap/vote.ts +++ b/apps/page/pages/api/roadmap/vote.ts @@ -5,10 +5,16 @@ import { getVisitorId } from "../../../lib/visitor-auth"; export default async function voteOnRoadmapItem( req: NextApiRequest, - res: NextApiResponse<{ success: boolean; vote_count?: number; error?: string }> + res: NextApiResponse<{ + success: boolean; + vote_count?: number; + error?: string; + }> ) { if (req.method !== "POST") { - return res.status(405).json({ success: false, error: "Method not allowed" }); + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); } const { item_id } = req.body; @@ -66,7 +72,9 @@ export default async function voteOnRoadmapItem( if (insertError) { console.error("voteOnRoadmapItem [Insert Error]", insertError); - return res.status(500).json({ success: false, error: "Failed to add vote" }); + return res + .status(500) + .json({ success: false, error: "Failed to add vote" }); } } @@ -84,7 +92,7 @@ export default async function voteOnRoadmapItem( success: true, vote_count: count || 0, }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("voteOnRoadmapItem [Error]", e); res.status(500).json({ success: false, error: "Internal server error" }); } diff --git a/apps/page/pages/api/roadmap/votes.ts b/apps/page/pages/api/roadmap/votes.ts index 0a7db33..4a61172 100644 --- a/apps/page/pages/api/roadmap/votes.ts +++ b/apps/page/pages/api/roadmap/votes.ts @@ -114,7 +114,7 @@ export default async function getBulkRoadmapItemVotes( success: true, votes, }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("getBulkRoadmapItemVotes [Error]", e); res.status(500).json({ success: false, votes: {} }); } diff --git a/apps/page/pages/api/robots.ts b/apps/page/pages/api/robots.ts index f61d2d5..dc66844 100644 --- a/apps/page/pages/api/robots.ts +++ b/apps/page/pages/api/robots.ts @@ -26,7 +26,7 @@ async function handler( if (!settings) throw new Error("Settings not found"); res.status(200).send(settings?.hide_search_engine ? DISALLOW : ALLOW); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("robots.txt [Error]", e); res.status(200).send(DISALLOW); } diff --git a/apps/page/pages/api/rss.ts b/apps/page/pages/api/rss.ts index d7f13fe..dd5abf7 100644 --- a/apps/page/pages/api/rss.ts +++ b/apps/page/pages/api/rss.ts @@ -1,3 +1,7 @@ +import { + convertMarkdownToHtml, + convertMarkdownToPlainText, +} from "@changes-page/utils"; import { Feed } from "feed"; import type { NextApiRequest, NextApiResponse } from "next"; import { @@ -5,10 +9,6 @@ import { fetchRenderData, translateHostToPageIdentifier, } from "../../lib/data"; -import { - convertMarkdownToHtml, - convertMarkdownToPlainText, -} from "@changes-page/utils"; import { getPageUrl, getPostUrl } from "../../lib/url"; async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -69,7 +69,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res .status(200) .send((req?.url ?? "rss").includes("rss") ? feed.rss2() : feed.atom1()); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("feed.rss [Error]", e); res.status(500).send("Something went wrong"); } diff --git a/apps/page/pages/api/sitemap.ts b/apps/page/pages/api/sitemap.ts index bde7e5d..1cb83dd 100644 --- a/apps/page/pages/api/sitemap.ts +++ b/apps/page/pages/api/sitemap.ts @@ -51,7 +51,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res.setHeader("Content-Type", "application/xml"); res.setHeader("content-disposition", 'inline; filename="sitemap.xml"'); res.status(200).send(result.toString()); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("Sitemap [Error]", e); res.status(500).send("Something went wrong"); } diff --git a/apps/web/components/roadmap/RoadmapBoard.tsx b/apps/web/components/roadmap/RoadmapBoard.tsx index 80fe6dd..e5c746f 100644 --- a/apps/web/components/roadmap/RoadmapBoard.tsx +++ b/apps/web/components/roadmap/RoadmapBoard.tsx @@ -2,24 +2,30 @@ import { IRoadmapBoard, IRoadmapCategory, IRoadmapColumn, + IRoadmapTriageItem, } from "@changes-page/supabase/types/page"; import { useMemo, useState } from "react"; import RoadmapColumn from "./RoadmapColumn"; import RoadmapItemModal from "./RoadmapItemModal"; +import TriageRow from "./TriageRow"; import { useRoadmapDragDrop } from "./hooks/useRoadmapDragDrop"; import { useRoadmapItems } from "./hooks/useRoadmapItems"; import { ItemsByColumn, RoadmapItemWithRelations } from "./types"; +type TriageItemForAdmin = Omit; + export default function RoadmapBoard({ board, columns, items, categories, + triageItems = [], }: { board: IRoadmapBoard; columns: IRoadmapColumn[]; items: RoadmapItemWithRelations[]; categories: IRoadmapCategory[]; + triageItems?: TriageItemForAdmin[]; }) { const [boardItems, setBoardItems] = useState(items); @@ -45,8 +51,45 @@ export default function RoadmapBoard({ itemsByColumn, }); + const handleTriageItemMoved = (newItem: RoadmapItemWithRelations) => { + setBoardItems((prev) => { + const itemsWithoutDuplicate = prev.filter((item) => item.id !== newItem.id); + + const itemsInTargetColumn = itemsWithoutDuplicate.filter( + (item) => item.column_id === newItem.column_id + ); + + const hasPositionConflict = itemsInTargetColumn.some( + (item) => item.position === newItem.position + ); + + if (hasPositionConflict) { + const adjustedItems = itemsWithoutDuplicate.map((item) => { + if (item.column_id === newItem.column_id && item.position >= newItem.position) { + return { ...item, position: item.position + 1 }; + } + return item; + }); + return [...adjustedItems, newItem]; + } + + return [...itemsWithoutDuplicate, newItem]; + }); + }; + + const handleTriageItemDeleted = () => { + }; + return ( <> + {triageItems.length > 0 && ( + + )} + ; + +interface TriageItemCardProps { + item: TriageItemForAdmin; + onMoveToBoard: (itemId: string) => Promise; + onDelete: (itemId: string) => Promise; +} + +function getTimeAgo(date: string): string { + const now = new Date(); + const past = new Date(date); + + if (isNaN(past.getTime())) { + return ""; + } + + const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000); + + if (diffInSeconds < 60) return "just now"; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; + return past.toLocaleDateString(); +} + +export default function TriageItemCard({ + item, + onMoveToBoard, + onDelete, +}: TriageItemCardProps) { + const [isMoving, setIsMoving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleMove = async () => { + setIsMoving(true); + try { + await onMoveToBoard(item.id); + } catch (error) { + console.error("Failed to move item:", error); + alert("Failed to move item to board. Please try again."); + } finally { + setIsMoving(false); + } + }; + + const handleDelete = async () => { + if (!confirm("Are you sure you want to delete this submission?")) return; + + setIsDeleting(true); + try { + await onDelete(item.id); + } catch (error) { + console.error("Failed to delete item:", error); + alert("Failed to delete item. Please try again."); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + + + {getTimeAgo(item.created_at)} + + + + + {isMoving ? "Moving..." : "Move to Board"} + + + + + + + + ); +} diff --git a/apps/web/components/roadmap/TriageRow.tsx b/apps/web/components/roadmap/TriageRow.tsx new file mode 100644 index 0000000..6682238 --- /dev/null +++ b/apps/web/components/roadmap/TriageRow.tsx @@ -0,0 +1,90 @@ +import { IRoadmapTriageItem } from "@changes-page/supabase/types/page"; +import { useState } from "react"; +import { httpPost } from "../../utils/http"; +import TriageItemCard from "./TriageItemCard"; +import { RoadmapItemWithRelations } from "./types"; + +type TriageItemForAdmin = Omit; + +interface TriageRowProps { + triageItems: TriageItemForAdmin[]; + onItemMoved: (newItem: RoadmapItemWithRelations) => void; + onItemDeleted?: () => void; +} + +export default function TriageRow({ + triageItems, + onItemMoved, + onItemDeleted, +}: TriageRowProps) { + const [localTriageItems, setLocalTriageItems] = useState(triageItems); + + const handleMoveToBoard = async (triageItemId: string) => { + try { + const response = await httpPost({ + url: "/api/roadmap/triage/move-to-board", + data: { triage_item_id: triageItemId }, + }); + + if (response.success && response.item) { + setLocalTriageItems((prev) => prev.filter((item) => item.id !== triageItemId)); + onItemMoved(response.item); + } + } catch (error) { + console.error("Error moving triage item:", error); + alert("Failed to move item to board"); + } + }; + + const handleDelete = async (triageItemId: string) => { + try { + const response = await httpPost({ + url: "/api/roadmap/triage/delete", + data: { triage_item_id: triageItemId }, + }); + + if (response.success) { + setLocalTriageItems((prev) => prev.filter((item) => item.id !== triageItemId)); + onItemDeleted?.(); + } + } catch (error) { + console.error("Error deleting triage item:", error); + alert("Failed to delete item"); + } + }; + + if (localTriageItems.length === 0) { + return null; + } + + return ( + + + + + + Triage + + {localTriageItems.length} + + + + User-submitted ideas waiting for review + + + + + {localTriageItems.map((item) => ( + + ))} + + + + + ); +} diff --git a/apps/web/pages/api/integrations/github/action-new-post.tsx b/apps/web/pages/api/integrations/github/action-new-post.tsx index 74327d9..c1ddef7 100644 --- a/apps/web/pages/api/integrations/github/action-new-post.tsx +++ b/apps/web/pages/api/integrations/github/action-new-post.tsx @@ -53,8 +53,13 @@ export default async function handler( res.status(200).json({ id: data.id, }); - } catch (e: Error | any) { + } catch (e: unknown) { console.error("[Github]: action-new-post error", e); - res.status(500).json({ error: { statusCode: 500, message: e.message } }); + res.status(500).json({ + error: { + statusCode: 500, + message: e instanceof Error ? e.message : String(e), + }, + }); } } diff --git a/apps/web/pages/api/integrations/zapier/action-new-post.tsx b/apps/web/pages/api/integrations/zapier/action-new-post.tsx index 5686334..2bf3a9f 100644 --- a/apps/web/pages/api/integrations/zapier/action-new-post.tsx +++ b/apps/web/pages/api/integrations/zapier/action-new-post.tsx @@ -49,8 +49,13 @@ export default async function handler( }); res.status(200).json(data); - } catch (e: Error | any) { + } catch (e: unknown) { console.error("Zapier action-new-post error", e); - res.status(500).json({ error: { statusCode: 500, message: e.message } }); + res.status(500).json({ + error: { + statusCode: 500, + message: e instanceof Error ? e.message : String(e), + }, + }); } } 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 bd23176..bd73a47 100644 --- a/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx +++ b/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx @@ -52,7 +52,7 @@ export default async function handler( })); res.status(200).json(postsWithUrl); - } catch (e: Error | any) { + } catch (e: any) { if (e.message.includes("Invalid")) { return res .status(400) diff --git a/apps/web/pages/api/pages/reactions.ts b/apps/web/pages/api/pages/reactions.ts index ad1b5e0..9646f8c 100644 --- a/apps/web/pages/api/pages/reactions.ts +++ b/apps/web/pages/api/pages/reactions.ts @@ -38,7 +38,7 @@ export default withAuth<{ ok: boolean; aggregate: any }>( rocket: aggregate[0].rocket_count, }, }); - } catch (e: Error | any) { + } catch (e: unknown) { console.log("getPostReactions [Error]", e); res.status(500).json({ ok: false, aggregate: null }); } diff --git a/apps/web/pages/api/roadmap/triage/delete.ts b/apps/web/pages/api/roadmap/triage/delete.ts new file mode 100644 index 0000000..1c9e07e --- /dev/null +++ b/apps/web/pages/api/roadmap/triage/delete.ts @@ -0,0 +1,73 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import { createAuditLog } from "../../../../utils/auditLog"; +import { withAuth } from "../../../../utils/withAuth"; + +const deleteTriageItem = withAuth<{ success: boolean; error?: string }>( + async (req, res, { supabase, user }) => { + if (req.method !== "POST") { + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); + } + + const { triage_item_id } = req.body; + + if (!triage_item_id) { + return res + .status(400) + .json({ success: false, error: "Missing triage_item_id" }); + } + + try { + const { data: triageItem, error: triageError } = await supabaseAdmin + .from("roadmap_triage_items") + .select( + "*, roadmap_boards!inner(id, page_id, pages!inner(id, user_id))" + ) + .eq("id", triage_item_id) + .single(); + + if (triageError || !triageItem) { + return res + .status(404) + .json({ success: false, error: "Triage item not found" }); + } + + if (triageItem.roadmap_boards.pages.user_id !== user.id) { + return res.status(403).json({ success: false, error: "Unauthorized" }); + } + + const { error: deleteError } = await supabaseAdmin + .from("roadmap_triage_items") + .delete() + .eq("id", triage_item_id); + + if (deleteError) { + console.error("deleteTriageItem [Delete Error]", deleteError); + return res + .status(500) + .json({ success: false, error: "Failed to delete triage item" }); + } + + try { + await createAuditLog(supabase, { + page_id: triageItem.roadmap_boards.page_id, + actor_id: user.id, + action: `Deleted Triage Item: ${triageItem.title}`, + changes: { triage_item_id, title: triageItem.title }, + }); + } catch (auditError) { + console.error("deleteTriageItem [Audit Log Error]", auditError); + } + + res.status(200).json({ + success: true, + }); + } catch (e: unknown) { + console.log("deleteTriageItem [Error]", e); + res.status(500).json({ success: false, error: "Internal server error" }); + } + } +); + +export default deleteTriageItem; diff --git a/apps/web/pages/api/roadmap/triage/move-to-board.ts b/apps/web/pages/api/roadmap/triage/move-to-board.ts new file mode 100644 index 0000000..d6b5b0e --- /dev/null +++ b/apps/web/pages/api/roadmap/triage/move-to-board.ts @@ -0,0 +1,133 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import { v4 } from "uuid"; +import { createAuditLog } from "../../../../utils/auditLog"; +import { withAuth } from "../../../../utils/withAuth"; + +const moveTriageToBoard = withAuth<{ + success: boolean; + item?: any; + error?: string; +}>(async (req, res, { supabase, user }) => { + if (req.method !== "POST") { + return res + .status(405) + .json({ success: false, error: "Method not allowed" }); + } + + const { triage_item_id } = req.body; + + if (!triage_item_id) { + return res + .status(400) + .json({ success: false, error: "Missing triage_item_id" }); + } + + try { + const { data: triageItem, error: triageError } = await supabaseAdmin + .from("roadmap_triage_items") + .select("*, roadmap_boards!inner(id, page_id, pages!inner(id, user_id))") + .eq("id", triage_item_id) + .single(); + + if (triageError || !triageItem) { + return res + .status(404) + .json({ success: false, error: "Triage item not found" }); + } + + if (triageItem.roadmap_boards.pages.user_id !== user.id) { + return res.status(403).json({ success: false, error: "Unauthorized" }); + } + + const { data: firstColumn, error: columnError } = await supabaseAdmin + .from("roadmap_columns") + .select("id") + .eq("board_id", triageItem.board_id) + .order("position", { ascending: true }) + .limit(1) + .single(); + + if (columnError || !firstColumn) { + return res + .status(500) + .json({ success: false, error: "Failed to find first column" }); + } + + const { data: columnItems, error: itemsError } = await supabaseAdmin + .from("roadmap_items") + .select("position") + .eq("column_id", firstColumn.id) + .order("position", { ascending: false }) + .limit(1); + + if (itemsError) { + return res + .status(500) + .json({ success: false, error: "Failed to calculate position" }); + } + + const maxPosition = + columnItems && columnItems.length > 0 ? columnItems[0].position : 0; + + const { data: newItem, error: insertError } = await supabaseAdmin + .from("roadmap_items") + .insert({ + id: v4(), + board_id: triageItem.board_id, + column_id: firstColumn.id, + title: triageItem.title, + description: triageItem.description, + category_id: null, + position: maxPosition + 1, + }) + .select( + `*, + roadmap_categories ( + id, + name, + color + ), + roadmap_votes ( + id + )` + ) + .single(); + + if (insertError) { + console.error("moveTriageToBoard [Insert Error]", insertError); + return res + .status(500) + .json({ success: false, error: "Failed to create roadmap item" }); + } + + const { error: deleteError } = await supabaseAdmin + .from("roadmap_triage_items") + .delete() + .eq("id", triage_item_id); + + if (deleteError) { + console.error("moveTriageToBoard [Delete Error]", deleteError); + } + + try { + await createAuditLog(supabase, { + page_id: triageItem.roadmap_boards.page_id, + actor_id: user.id, + action: `Moved Triage Item to Board: ${newItem.title}`, + changes: { triage_item_id, roadmap_item_id: newItem.id }, + }); + } catch (auditError) { + console.error("moveTriageToBoard [Audit Log Error]", auditError); + } + + res.status(200).json({ + success: true, + item: newItem, + }); + } catch (e: unknown) { + console.log("moveTriageToBoard [Error]", e); + res.status(500).json({ success: false, error: "Internal server error" }); + } +}); + +export default moveTriageToBoard; diff --git a/apps/web/pages/pages/[page_id]/roadmap/[board_id].tsx b/apps/web/pages/pages/[page_id]/roadmap/[board_id].tsx index 9e2d0c1..514e799 100644 --- a/apps/web/pages/pages/[page_id]/roadmap/[board_id].tsx +++ b/apps/web/pages/pages/[page_id]/roadmap/[board_id].tsx @@ -80,6 +80,16 @@ export const getServerSideProps = withSupabase(async (ctx, { supabase }) => { new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); + const { data: triageItems, error: triageError } = await supabase + .from("roadmap_triage_items") + .select("id, board_id, title, description, created_at, updated_at") + .eq("board_id", board_id) + .order("created_at", { ascending: false }); + + if (triageError) { + console.error("Failed to fetch triage items", triageError); + } + return { props: { page_id, @@ -89,6 +99,7 @@ export const getServerSideProps = withSupabase(async (ctx, { supabase }) => { columns: columns || [], items: items || [], categories: categories || [], + triageItems: triageItems || [], }, }; }); @@ -99,6 +110,7 @@ export default function RoadmapBoardDetails({ columns, items, categories, + triageItems, }: InferGetServerSidePropsType) { const router = useRouter(); @@ -140,6 +152,7 @@ export default function RoadmapBoardDetails({ board={board} columns={columns} items={items} + triageItems={triageItems} categories={categories} /> diff --git a/packages/supabase/migrations/20_roadmap_triage_items.sql b/packages/supabase/migrations/20_roadmap_triage_items.sql new file mode 100644 index 0000000..0e556d0 --- /dev/null +++ b/packages/supabase/migrations/20_roadmap_triage_items.sql @@ -0,0 +1,30 @@ +create table roadmap_triage_items ( + id uuid default uuid_generate_v4() primary key, + board_id uuid references roadmap_boards(id) on delete cascade not null, + title text not null, + description text, + visitor_id uuid references page_visitors(id) on delete cascade not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +create index roadmap_triage_items_board_id_idx on roadmap_triage_items(board_id); + +alter table roadmap_triage_items enable row level security; + +create policy "Page owners can view triage items." on roadmap_triage_items + for select + using (board_id in (select id from roadmap_boards where page_id in (select id from pages))); + +create policy "Page owners can update triage items." on roadmap_triage_items + for update + using (board_id in (select id from roadmap_boards where page_id in (select id from pages))); + +create policy "Page owners can delete triage items." on roadmap_triage_items + for delete + using (board_id in (select id from roadmap_boards where page_id in (select id from pages))); + +CREATE TRIGGER set_timestamp +BEFORE UPDATE ON roadmap_triage_items +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts index 924ae08..f337853 100644 --- a/packages/supabase/types/index.ts +++ b/packages/supabase/types/index.ts @@ -588,6 +588,51 @@ export type Database = { }, ] } + roadmap_triage_items: { + Row: { + board_id: string + created_at: string + description: string | null + id: string + title: string + updated_at: string + visitor_id: string + } + Insert: { + board_id: string + created_at?: string + description?: string | null + id?: string + title: string + updated_at?: string + visitor_id: string + } + Update: { + board_id?: string + created_at?: string + description?: string | null + id?: string + title?: string + updated_at?: string + visitor_id?: string + } + Relationships: [ + { + foreignKeyName: "roadmap_triage_items_board_id_fkey" + columns: ["board_id"] + isOneToOne: false + referencedRelation: "roadmap_boards" + referencedColumns: ["id"] + }, + { + foreignKeyName: "roadmap_triage_items_visitor_id_fkey" + columns: ["visitor_id"] + isOneToOne: false + referencedRelation: "page_visitors" + referencedColumns: ["id"] + }, + ] + } roadmap_votes: { Row: { created_at: string diff --git a/packages/supabase/types/page.ts b/packages/supabase/types/page.ts index 652f48d..c9b597c 100644 --- a/packages/supabase/types/page.ts +++ b/packages/supabase/types/page.ts @@ -14,6 +14,7 @@ export type IRoadmapColumn = Tables<"roadmap_columns">; export type IRoadmapCategory = Tables<"roadmap_categories">; export type IRoadmapItem = Tables<"roadmap_items">; export type IRoadmapVote = Tables<"roadmap_votes">; +export type IRoadmapTriageItem = Tables<"roadmap_triage_items">; export enum PageType { changelogs = "changelogs",
{error}
- {board.description} -
+ {board.description} +
+ {item.description} +
+ User-submitted ideas waiting for review +