From ff9efe6fc99858559db865f8498105747df02cf4 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 18 Aug 2024 17:50:54 -0500 Subject: [PATCH] Course publishing with partial drafts barely works --- .../content/courses/CourseDetails.js | 4 + .../content/courses/DraftCourseDetails.js | 217 ++++++++++++++++-- .../content/lists/ContentListItem.js | 5 +- src/components/forms/EditCourseForm.js | 1 + src/components/profile/UserContent.js | 13 +- src/db/models/courseDraftModels.js | 1 + src/hooks/apiQueries/useCourseDraftsQuery.js | 46 ++++ src/pages/api/courses/drafts/[slug].js | 8 +- src/pages/course/[slug]/draft/index.js | 46 ++-- src/pages/course/[slug]/index.js | 2 +- 10 files changed, 298 insertions(+), 45 deletions(-) create mode 100644 src/hooks/apiQueries/useCourseDraftsQuery.js diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index 669e25c..1b0dc74 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/CourseDetails.js @@ -38,6 +38,10 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec console.log("processedEvent", processedEvent); }, [processedEvent]); + useEffect(() => { + console.log("lessons", lessons); + }, [lessons]); + useEffect(() => { console.log("zaps", zaps); }, [zaps]); diff --git a/src/components/content/courses/DraftCourseDetails.js b/src/components/content/courses/DraftCourseDetails.js index bf9ec73..684f51a 100644 --- a/src/components/content/courses/DraftCourseDetails.js +++ b/src/components/content/courses/DraftCourseDetails.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useRouter } from 'next/router'; import { useImageProxy } from '@/hooks/useImageProxy'; import { Tag } from 'primereact/tag'; @@ -6,6 +6,7 @@ import { Button } from 'primereact/button'; import Image from 'next/image'; import dynamic from 'next/dynamic'; import axios from 'axios'; +import { nip04, nip19 } from 'nostr-tools'; import { v4 as uuidv4 } from 'uuid'; import { useSession } from 'next-auth/react'; import { useNDKContext } from "@/context/NDKContext"; @@ -21,9 +22,30 @@ const MDDisplay = dynamic( } ); +function validateEvent(event) { + if (typeof event.kind !== "number") return "Invalid kind"; + if (typeof event.content !== "string") return "Invalid content"; + if (typeof event.created_at !== "number") return "Invalid created_at"; + if (typeof event.pubkey !== "string") return "Invalid pubkey"; + if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format"; + + if (!Array.isArray(event.tags)) return "Invalid tags"; + for (let i = 0; i < event.tags.length; i++) { + const tag = event.tags[i]; + if (!Array.isArray(tag)) return "Invalid tag structure"; + for (let j = 0; j < tag.length; j++) { + if (typeof tag[j] === "object") return "Invalid tag value"; + } + } + + return true; +} + export default function DraftCourseDetails({ processedEvent, draftId, lessons }) { const [author, setAuthor] = useState(null); const [user, setUser] = useState(null); + const [processedLessons, setProcessedLessons] = useState([]); + const hasRunEffect = useRef(false); const { showToast } = useToast(); const { returnImageProxy } = useImageProxy(); @@ -41,6 +63,10 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) } }, [ndk]); + useEffect(() => { + console.log('lessons in comp', lessons); + }, [lessons]); + useEffect(() => { if (processedEvent) { fetchAuthor(processedEvent?.user?.pubkey); @@ -55,33 +81,98 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) const handleDelete = () => { axios.delete(`/api/courses/drafts/${processedEvent.id}`) - .then(() => { - showToast('success', 'Success', 'Draft Course deleted successfully'); - router.push('/'); - }) - .catch((error) => { - showToast('error', 'Error', 'Failed to delete draft course'); - }); + .then(() => { + showToast('success', 'Success', 'Draft Course deleted successfully'); + router.push('/'); + }) + .catch((error) => { + showToast('error', 'Error', 'Failed to delete draft course'); + }); } + const handlePostResource = async (resource) => { + console.log('resourceeeeee:', resource.tags); + const dTag = resource.tags.find(tag => tag[0] === 'd')[1]; + let price + + try { + price = resource.tags.find(tag => tag[0] === 'price')[1]; + } catch (err) { + price = 0; + } + + const nAddress = nip19.naddrEncode({ + pubkey: resource.pubkey, + kind: resource.kind, + identifier: dTag, + }); + + const userResponse = await axios.get(`/api/users/${user.pubkey}`); + + if (!userResponse.data) { + showToast('error', 'Error', 'User not found', 'Please try again.'); + return; + } + + const payload = { + id: dTag, + userId: userResponse.data.id, + price: Number(price), + noteId: nAddress + }; + + const response = await axios.post(`/api/resources`, payload); + + if (response.status !== 201) { + showToast('error', 'Error', 'Failed to create resource. Please try again.'); + return; + } + + return response.data; + }; + const handleSubmit = async (e) => { e.preventDefault(); const newCourseId = uuidv4(); - const processedLessons = []; try { // Step 0: Add signer if not already added if (!ndk.signer) { await addSigner(); - } + } // Step 1: Process lessons - for (const lesson of lessons) { - processedLessons.push({ - d: lesson?.d, - kind: lesson?.price ? 30402 : 30023, - pubkey: lesson.pubkey - }); + for (const lesson of processedLessons) { + // publish any draft lessons and delete draft lessons + const unpublished = lesson?.unpublished; + if (unpublished && Object.keys(unpublished).length > 0) { + const validationResult = validateEvent(unpublished); + if (validationResult !== true) { + console.error('Invalid event:', validationResult); + showToast('error', 'Error', `Invalid event: ${validationResult}`); + return; + } + + const published = await unpublished.publish(); + + const saved = await handlePostResource(unpublished); + + console.log('saved', saved); + + if (published && saved) { + axios.delete(`/api/drafts/${lesson?.d}`) + .then(res => { + if (res.status === 204) { + showToast('success', 'Success', 'Draft deleted successfully.'); + } else { + showToast('error', 'Error', 'Failed to delete draft.'); + } + }) + .catch(err => { + console.error(err); + }); + } + } } // Step 2: Create and publish course @@ -95,7 +186,6 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) } // Step 3: Save course to db - console.log('processedLessons:', processedLessons); await axios.post('/api/courses', { id: newCourseId, resources: { @@ -141,6 +231,99 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) return event; }; + useEffect(() => { + async function buildEvent(draft) { + const event = new NDKEvent(ndk); + let type; + let encryptedContent; + + console.log('Draft:', draft); + + switch (draft?.type) { + case 'resource': + if (draft?.price) { + // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY + encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content); + } + + event.kind = draft?.price ? 30402 : 30023; // Determine kind based on if price is present + event.content = draft?.price ? encryptedContent : draft.content; + event.created_at = Math.floor(Date.now() / 1000); + event.pubkey = user.pubkey; + event.tags = [ + ['d', draft.id], + ['title', draft.title], + ['summary', draft.summary], + ['image', draft.image], + ...draft.topics.map(topic => ['t', topic]), + ['published_at', Math.floor(Date.now() / 1000).toString()], + ...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []), + ]; + + type = 'resource'; + break; + case 'workshop': + if (draft?.price) { + // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY + encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content); + } + + event.kind = draft?.price ? 30402 : 30023; + event.content = draft?.price ? encryptedContent : draft.content; + event.created_at = Math.floor(Date.now() / 1000); + event.pubkey = user.pubkey; + event.tags = [ + ['d', draft.id], + ['title', draft.title], + ['summary', draft.summary], + ['image', draft.image], + ...draft.topics.map(topic => ['t', topic]), + ['published_at', Math.floor(Date.now() / 1000).toString()], + ...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []), + ]; + + type = 'workshop'; + break; + default: + return null; + } + + return { unsignedEvent: event, type }; + } + + async function buildDraftEvent(lesson) { + const { unsignedEvent, type } = await buildEvent(lesson); + return unsignedEvent + } + + if (!hasRunEffect.current && lessons.length > 0 && user && author) { + hasRunEffect.current = true; + + lessons.forEach(async (lesson) => { + const isDraft = !lesson?.pubkey; + if (isDraft) { + const unsignedEvent = await buildDraftEvent(lesson); + setProcessedLessons(prev => [...prev, { + d: lesson?.id, + kind: lesson?.price ? 30402 : 30023, + pubkey: unsignedEvent.pubkey, + unpublished: unsignedEvent + }]); + } else { + setProcessedLessons(prev => [...prev, { + d: lesson?.d, + kind: lesson?.price ? 30402 : 30023, + pubkey: lesson.pubkey + }]); + } + }); + } + }, [lessons, user, author, ndk]); + + useEffect(() => { + console.log('processedLessons', processedLessons); + }, [processedLessons]); + return (
diff --git a/src/components/content/lists/ContentListItem.js b/src/components/content/lists/ContentListItem.js index 0f6cd33..e3c418a 100644 --- a/src/components/content/lists/ContentListItem.js +++ b/src/components/content/lists/ContentListItem.js @@ -9,6 +9,7 @@ const ContentListItem = (content) => { const router = useRouter(); const isDraft = Object.keys(content).includes('type'); const isCourse = content && content?.kind === 30004; + const isCourseDraft = content && content?.resources?.length > 0 && !content?.kind; const handleClick = () => { let path = ''; @@ -17,11 +18,13 @@ const ContentListItem = (content) => { path = '/draft'; } else if (isCourse) { path = '/course'; + } else if (isCourseDraft) { + path = `/course/${content.id}/draft` + return router.push(path); } else { path = '/details'; } - // const draftSuffix = isCourse ? '/draft' : ''; const fullPath = `${path}/${content.id}`; router.push(fullPath); diff --git a/src/components/forms/EditCourseForm.js b/src/components/forms/EditCourseForm.js index 5956298..0e011e7 100644 --- a/src/components/forms/EditCourseForm.js +++ b/src/components/forms/EditCourseForm.js @@ -18,6 +18,7 @@ import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownI import 'primeicons/primeicons.css'; // todo dealing with adding drafts as new lessons +// todo disable ability to add a free lesson to a paid course and vice versa (or just make the user remove the lesson if they want to change the price) // todo deal with error where 2 new lessons popup when only one is added from the dropdown // todo on edit lessons need to make sure that the user is still choosing the order those lessons appear in the course const EditCourseForm = ({ draft }) => { diff --git a/src/components/profile/UserContent.js b/src/components/profile/UserContent.js index 6f7962d..bbf0bdb 100644 --- a/src/components/profile/UserContent.js +++ b/src/components/profile/UserContent.js @@ -6,6 +6,7 @@ import { useCoursesQuery } from "@/hooks/nostrQueries/content/useCoursesQuery"; import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery"; import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery"; import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery"; +import { useCourseDraftsQuery } from "@/hooks/apiQueries/useCourseDraftsQuery"; import { useContentIdsQuery } from "@/hooks/apiQueries/useContentIdsQuery"; import { useSession } from "next-auth/react"; import { useToast } from "@/hooks/useToast"; @@ -29,6 +30,7 @@ const UserContent = () => { const { courses, coursesLoading, coursesError } = useCoursesQuery(); const { resources, resourcesLoading, resourcesError } = useResourcesQuery(); const { workshops, workshopsLoading, workshopsError } = useWorkshopsQuery(); + const { courseDrafts, courseDraftsLoading, courseDraftsError } = useCourseDraftsQuery(); const { drafts, draftsLoading, draftsError } = useDraftsQuery(); const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery(); @@ -45,7 +47,8 @@ const UserContent = () => { const contentItems = [ { label: "Published", icon: "pi pi-verified" }, { label: "Drafts", icon: "pi pi-file-edit" }, - { label: "Resources", icon: "pi pi-book" }, + { label: "Draft Courses", icon: "pi pi-book" }, + { label: "Resources", icon: "pi pi-file" }, { label: "Workshops", icon: "pi pi-video" }, { label: "Courses", icon: "pi pi-desktop" }, ]; @@ -90,6 +93,8 @@ const UserContent = () => { case 1: return drafts || []; case 2: + return courseDrafts || []; + case 3: return resources?.map(parseEvent) || []; case 3: return workshops?.map(parseEvent) || []; @@ -102,10 +107,10 @@ const UserContent = () => { setContent(getContentByIndex(activeIndex)); } - }, [activeIndex, isClient, drafts, resources, workshops, courses, publishedContent]) + }, [activeIndex, isClient, drafts, resources, workshops, courses, publishedContent, courseDrafts]) - const isLoading = coursesLoading || resourcesLoading || workshopsLoading || draftsLoading || contentIdsLoading; - const isError = coursesError || resourcesError || workshopsError || draftsError || contentIdsError; + const isLoading = coursesLoading || resourcesLoading || workshopsLoading || draftsLoading || contentIdsLoading || courseDraftsLoading; + const isError = coursesError || resourcesError || workshopsError || draftsError || contentIdsError || courseDraftsError; return (
diff --git a/src/db/models/courseDraftModels.js b/src/db/models/courseDraftModels.js index 5c904df..15bb0ee 100644 --- a/src/db/models/courseDraftModels.js +++ b/src/db/models/courseDraftModels.js @@ -18,6 +18,7 @@ export const getCourseDraftById = async (id) => { include: { user: true, // Include the related user resources: true, // Include related resources + drafts: true, // Include related drafts }, }); }; diff --git a/src/hooks/apiQueries/useCourseDraftsQuery.js b/src/hooks/apiQueries/useCourseDraftsQuery.js new file mode 100644 index 0000000..fae3125 --- /dev/null +++ b/src/hooks/apiQueries/useCourseDraftsQuery.js @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { useSession } from 'next-auth/react'; + +export function useCourseDraftsQuery() { + const [isClient, setIsClient] = useState(false); + const { data: session, status } = useSession(); + const [user, setUser] = useState(null); + + useEffect(() => { + if (session) { + setUser(session.user); + } + }, [session]); + + useEffect(() => { + setIsClient(true); + }, []); + + const fetchCourseDrafts = async () => { + try { + if (!user?.id) { + return []; + } + const response = await axios.get(`/api/courses/drafts/${user.id}/all`); + return response.data; + } catch (error) { + console.error('Error fetching course drafts:', error); + return []; + } + }; + + const { + data: courseDrafts, + isLoading: courseDraftsLoading, + error: courseDraftsError, + refetch: refetchCourseDrafts + } = useQuery({ + queryKey: ['courseDrafts', isClient], + queryFn: fetchCourseDrafts, + enabled: isClient && !!user?.id, // Only enable if client-side and user ID is available + }); + + return { courseDrafts, courseDraftsLoading, courseDraftsError, refetchCourseDrafts }; +} diff --git a/src/pages/api/courses/drafts/[slug].js b/src/pages/api/courses/drafts/[slug].js index acbdde9..e68584a 100644 --- a/src/pages/api/courses/drafts/[slug].js +++ b/src/pages/api/courses/drafts/[slug].js @@ -9,8 +9,14 @@ export default async function handler(req, res) { if (slug && !userId) { try { const courseDraft = await getCourseDraftById(slug); + + // For now we will combine resources and drafts into one array + const courseDraftWithResources = { + ...courseDraft, + resources: [...courseDraft.resources, ...courseDraft.drafts] + }; if (courseDraft) { - res.status(200).json(courseDraft); + res.status(200).json(courseDraftWithResources); } else { res.status(404).json({ error: 'Course draft not found' }); } diff --git a/src/pages/course/[slug]/draft/index.js b/src/pages/course/[slug]/draft/index.js index 06633f2..36ad4fe 100644 --- a/src/pages/course/[slug]/draft/index.js +++ b/src/pages/course/[slug]/draft/index.js @@ -4,17 +4,11 @@ import axios from "axios"; import { parseEvent, findKind0Fields } from "@/utils/nostr"; import DraftCourseDetails from "@/components/content/courses/DraftCourseDetails"; import DraftCourseLesson from "@/components/content/courses/DraftCourseLesson"; -import dynamic from 'next/dynamic'; import { useNDKContext } from "@/context/NDKContext"; - -const MDDisplay = dynamic( - () => import("@uiw/react-markdown-preview"), - { - ssr: false, - } -); +import { useSession } from "next-auth/react"; const DraftCourse = () => { + const { data: session } = useSession(); const [course, setCourse] = useState(null); const [lessons, setLessons] = useState([]); const [lessonsWithAuthors, setLessonsWithAuthors] = useState([]); @@ -40,7 +34,6 @@ const DraftCourse = () => { axios.get(`/api/courses/drafts/${slug}`) .then(res => { - console.log('res:', res.data); setCourse(res.data); console.log('coursesssss:', res.data); setLessons(res.data.resources); // Set the raw lessons @@ -54,22 +47,33 @@ const DraftCourse = () => { useEffect(() => { const fetchLessonDetails = async () => { if (lessons.length > 0) { + console.log('lessons in fetchLessonDetails', lessons); await ndk.connect(); const newLessonsWithAuthors = await Promise.all(lessons.map(async (lesson) => { - const filter = { - "#d": [lesson.id] - }; - - const event = await ndk.fetchEvent(filter); - if (event) { - const author = await fetchAuthor(event.pubkey); - return { - ...parseEvent(event), - author + // figure out if it is a resource or a draft + const isDraft = !lesson.noteId; + if (isDraft) { + const parsedLessonObject = { + ...lesson, + author: session.user + } + return parsedLessonObject; + } else { + const filter = { + "#d": [lesson.id] }; + + const event = await ndk.fetchEvent(filter); + if (event) { + const author = await fetchAuthor(event.pubkey); + return { + ...parseEvent(event), + author + }; + } } - + return lesson; // Fallback to the original lesson if no event found })); @@ -78,7 +82,7 @@ const DraftCourse = () => { }; fetchLessonDetails(); - }, [lessons, ndk, fetchAuthor]); + }, [lessons, ndk, fetchAuthor, session]); return ( <> diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index da07c12..24f15b8 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -107,7 +107,7 @@ const Course = () => { }, [lessonIds, ndk, fetchAuthor]); useEffect(() => { - if (course?.price) { + if (course?.price && course?.price > 0) { setPaidCourse(true); } }, [course]);