diff --git a/prisma/migrations/20240809152643_init/migration.sql b/prisma/migrations/20240816152315_init/migration.sql similarity index 86% rename from prisma/migrations/20240809152643_init/migration.sql rename to prisma/migrations/20240816152315_init/migration.sql index 5c116ab..ba97517 100644 --- a/prisma/migrations/20240809152643_init/migration.sql +++ b/prisma/migrations/20240816152315_init/migration.sql @@ -49,6 +49,7 @@ CREATE TABLE "Resource" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "courseId" TEXT, + "courseDraftId" TEXT, "price" INTEGER NOT NULL DEFAULT 0, "noteId" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -82,18 +83,14 @@ CREATE TABLE "CourseDraft" ( "summary" TEXT NOT NULL, "image" TEXT, "price" INTEGER DEFAULT 0, + "topics" TEXT[], "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + "courseId" TEXT, CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "_CourseDraftToResource" ( - "A" TEXT NOT NULL, - "B" TEXT NOT NULL -); - -- CreateIndex CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); @@ -107,10 +104,7 @@ CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId"); CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId"); -- CreateIndex -CREATE UNIQUE INDEX "_CourseDraftToResource_AB_unique" ON "_CourseDraftToResource"("A", "B"); - --- CreateIndex -CREATE INDEX "_CourseDraftToResource_B_index" ON "_CourseDraftToResource"("B"); +CREATE UNIQUE INDEX "CourseDraft_courseId_key" ON "CourseDraft"("courseId"); -- AddForeignKey ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE; @@ -133,6 +127,9 @@ ALTER TABLE "Resource" ADD CONSTRAINT "Resource_userId_fkey" FOREIGN KEY ("userI -- AddForeignKey ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseDraftId_fkey" FOREIGN KEY ("courseDraftId") REFERENCES "CourseDraft"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -140,7 +137,4 @@ ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") RE ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_A_fkey" FOREIGN KEY ("A") REFERENCES "CourseDraft"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_B_fkey" FOREIGN KEY ("B") REFERENCES "Resource"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1710cf5..52d35c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,7 +2,6 @@ datasource db { provider = "postgresql" url = env("DATABASE_URL") } - generator client { provider = "prisma-client-js" } @@ -13,10 +12,10 @@ model User { username String? @unique avatar String? purchased Purchase[] - courses Course[] // Relation field added for courses created by the user - resources Resource[] // Relation field added for resources created by the user - drafts Draft[] // Relation field added for drafts created by the user - courseDrafts CourseDraft[] // Relation field added for course drafts created by the user + courses Course[] + resources Resource[] + courseDrafts CourseDraft[] + drafts Draft[] role Role? @relation(fields: [roleId], references: [id]) roleId String? createdAt DateTime @default(now()) @@ -37,13 +36,13 @@ model Purchase { courseId String? resource Resource? @relation(fields: [resourceId], references: [id]) resourceId String? - amountPaid Int // in satoshis + amountPaid Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Course { - id String @id // Client generates UUID + id String @id userId String user User @relation(fields: [userId], references: [id]) price Int @default(0) @@ -52,20 +51,22 @@ model Course { noteId String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + courseDraft CourseDraft? } model Resource { - id String @id // Client generates UUID - userId String - user User @relation(fields: [userId], references: [id]) - course Course? @relation(fields: [courseId], references: [id]) - courseId String? - price Int @default(0) - purchases Purchase[] - courseDrafts CourseDraft[] - noteId String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id // Client generates UUID + userId String + user User @relation(fields: [userId], references: [id]) + course Course? @relation(fields: [courseId], references: [id]) + courseId String? + courseDraft CourseDraft? @relation(fields: [courseDraftId], references: [id]) + courseDraftId String? + price Int @default(0) + purchases Purchase[] + noteId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Draft { @@ -84,14 +85,17 @@ model Draft { } model CourseDraft { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) resources Resource[] title String summary String image String? - price Int? @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} + price Int? @default(0) + topics String[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + course Course? @relation(fields: [courseId], references: [id]) + courseId String? @unique +} \ No newline at end of file diff --git a/src/components/content/courses/DraftCourseDetails.js b/src/components/content/courses/DraftCourseDetails.js index dfa8f11..52e510a 100644 --- a/src/components/content/courses/DraftCourseDetails.js +++ b/src/components/content/courses/DraftCourseDetails.js @@ -54,7 +54,14 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons }) }, [session]); const handleDelete = () => { - console.log('delete'); + 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'); + }); } const handleSubmit = async (e) => { diff --git a/src/components/forms/CourseForm.js b/src/components/forms/CourseForm.js index 2ff2000..0c55e22 100644 --- a/src/components/forms/CourseForm.js +++ b/src/components/forms/CourseForm.js @@ -19,6 +19,9 @@ import { parseEvent } from "@/utils/nostr"; import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem"; import 'primeicons/primeicons.css'; + +// todo dont allow adding courses as resources +// todo need to update how I handle unpubbed resources const CourseForm = ({ draft = null, isPublished = false }) => { const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); @@ -96,140 +99,37 @@ const CourseForm = ({ draft = null, isPublished = false }) => { const handleSubmit = async (e) => { e.preventDefault(); - if (!ndk.signer) { - await addSigner(); + if (!user) { + showToast('error', 'Error', 'User not authenticated'); + return; } - // Prepare the lessons from selected lessons - const resources = await Promise.all(selectedLessons.map(async (lesson) => { - if (lesson?.type) { - const event = createLessonEvent(lesson); - const published = await event.publish(); - - if (!published) { - throw new Error(`Failed to publish lesson: ${lesson.title}`); - } - - const resource = await axios.post('/api/resources', { - id: event.tags.find(tag => tag[0] === 'd')[1], - userId: user.id, - price: lesson.price || 0, - noteId: event.id, - }); - - if (resource.status !== 201) { - throw new Error(`Failed to post resource: ${lesson.title}`); - } - - const deleted = await axios.delete(`/api/drafts/${lesson.id}`); - - if (deleted.status !== 204) { - throw new Error(`Failed to delete draft: ${lesson.title}`); - } - - return { - id: lesson.id, - userId: user.id, - price: lesson.price || 0, - noteId: event.id, - } - } else { - return { - id: lesson.d, - userId: user.id, - price: lesson.price || 0, - noteId: lesson.id, - } - } - })); - - // if this is a draft any added resources should be updated with courseId - if (draft) { - resources.forEach(resource => { - console.log('each resource:', resource); - if (!draft.resources.includes(resource.id)) { - axios.put(`/api/resources/${resource.id}`, { courseId: draft.id }) - .then(response => { - console.log('resource updated:', response); - }) - .catch(error => { - console.error('error updating resource:', error); - }); - } - }); - } - - const payload = { - user: { - connect: { id: user.id }, - }, - title, - summary, - image: coverImage, - price: price || 0, - resources: { - set: resources.map(resource => ({ id: resource.id })), - }, - topics, - }; - try { - let response; - if (draft) { - // payload minus topics - delete payload.topics - response = await axios.put(`/api/courses/drafts/${draft.id}`, payload); - showToast('success', 'Success', 'Course draft updated successfully'); - } else { - response = await axios.post('/api/courses/drafts', payload); - showToast('success', 'Success', 'Course draft saved successfully'); + // Step 1: Create the course draft + const courseDraftPayload = { + userId: user.id, // Make sure this is set + title, + summary, + image: coverImage, + price: checked ? price : 0, + topics, + }; + + const courseDraftResponse = await axios.post('/api/courses/drafts', courseDraftPayload); + const courseDraftId = courseDraftResponse.data.id; + + // Step 2: Associate resources with the course draft + for (const lesson of selectedLessons) { + await axios.put(`/api/resources/${lesson.d}`, { + courseDraftId: courseDraftId + }); } - console.log('response:', response); - // router.push(`/course/${response.data.id}/draft`); + + showToast('success', 'Success', 'Course draft saved successfully'); + router.push(`/course/${courseDraftId}/draft`); } catch (error) { console.error('Error saving course draft:', error); - showToast('error', 'Failed to save course draft. Please try again.'); - } - }; - - const handlePublishedCourse = async (e) => { - e.preventDefault(); - - if (!ndk.signer) { - await addSigner(); - } - - const event = new NDKEvent(ndk); - event.kind = price > 0 ? 30402 : 30023; - event.content = JSON.stringify({ - title, - summary, - image: coverImage, - resources: selectedLessons.map(lesson => lesson.id), - }); - event.tags = [ - ['d', draft.id], - ['title', title], - ['summary', summary], - ['image', coverImage], - ...topics.map(topic => ['t', topic]), - ['published_at', Math.floor(Date.now() / 1000).toString()], - ['price', price.toString()], - ]; - - try { - const published = await ndk.publish(event); - - if (published) { - const response = await axios.put(`/api/courses/${draft.id}`, { noteId: event.id }); - showToast('success', 'Success', 'Course published successfully'); - router.push(`/course/${event.id}`); - } else { - showToast('error', 'Error', 'Failed to publish course. Please try again.'); - } - } catch (error) { - console.error('Error publishing course:', error); - showToast('error', 'Failed to publish course. Please try again.'); + showToast('error', 'Failed to save course draft', error.response?.data?.details || error.message); } }; @@ -340,7 +240,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => { } return ( -
); diff --git a/src/components/forms/EditCourseForm.js b/src/components/forms/EditCourseForm.js new file mode 100644 index 0000000..f0a2437 --- /dev/null +++ b/src/components/forms/EditCourseForm.js @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { InputText } from "primereact/inputtext"; +import { InputNumber } from "primereact/inputnumber"; +import { InputSwitch } from "primereact/inputswitch"; +import { Button } from "primereact/button"; +import { Dropdown } from "primereact/dropdown"; +import { ProgressSpinner } from "primereact/progressspinner"; +import { useSession } from 'next-auth/react'; +import { useRouter } from "next/router"; +import { useToast } from "@/hooks/useToast"; +import { useNDKContext } from "@/context/NDKContext"; +import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery"; +import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery"; +import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery"; +import { parseEvent } from "@/utils/nostr"; +import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem"; +import 'primeicons/primeicons.css'; + +// todo dealing with adding drafts as new lessons +// 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 }) => { + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [checked, setChecked] = useState(false); + const [price, setPrice] = useState(0); + const [coverImage, setCoverImage] = useState(''); + const [selectedLessons, setSelectedLessons] = useState([]); + const [selectedLessonsLoading, setSelectedLessonsLoading] = useState(false); + const [topics, setTopics] = useState(['']); + + const { ndk } = useNDKContext(); + const { resources, resourcesLoading } = useResourcesQuery(); + const { workshops, workshopsLoading } = useWorkshopsQuery(); + const { drafts, draftsLoading } = useDraftsQuery(); + const { data: session } = useSession(); + const router = useRouter(); + const { showToast } = useToast(); + + useEffect(() => { + if (draft) { + const fetchLessonEventFromNostr = async (eventId) => { + try { + await ndk.connect(); + const fetchedEvent = await ndk.fetchEvent(eventId); + return fetchedEvent ? parseEvent(fetchedEvent) : null; + } catch (error) { + showToast('error', 'Error', `Failed to fetch lesson: ${eventId}`); + return null; + } + }; + + const fetchLessons = async () => { + const fetchedLessons = await Promise.all( + draft.resources.map(lesson => fetchLessonEventFromNostr(lesson.noteId)) + ); + setSelectedLessons(fetchedLessons.filter(Boolean)); + }; + + fetchLessons(); + setTitle(draft.title); + setSummary(draft.summary); + setChecked(draft.price > 0); + setPrice(draft.price || 0); + setCoverImage(draft.image); + setTopics(draft.topics || ['']); + } + }, [draft, ndk, showToast, parseEvent]); + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + // Ensure selectedLessons is an array + const lessonsToUpdate = Array.isArray(selectedLessons) ? selectedLessons : []; + + // Update newly added lessons with courseDraftId + const updatePromises = lessonsToUpdate + .filter(lesson => lesson && lesson.id && !draft.resources.some(r => r.id === lesson.id)) + .map(lesson => + axios.put(`/api/resources/${lesson.d}`, { courseDraftId: draft.id }) + ); + + await Promise.all(updatePromises); + + // Prepare payload for course draft update + const payload = { + id: draft.id, // Include the id in the payload + title, + summary, + image: coverImage, + price: checked ? price : 0, + topics, + resourceIds: lessonsToUpdate.filter(lesson => lesson && lesson.id).map(lesson => lesson.id) + }; + + // Update course draft + const response = await axios.put(`/api/courses/drafts/${draft.id}`, payload); + console.log('Update response:', response.data); + + showToast('success', 'Success', 'Course draft updated successfully'); + router.push(`/course/${draft.id}/draft`); + } catch (error) { + console.error('Error updating course draft:', error); + showToast('error', 'Failed to update course draft', error.response?.data?.details || error.message); + } + }; + + const handleLessonSelect = (content) => { + if (!selectedLessons.some(lesson => lesson.id === content.id)) { + setSelectedLessons(prevLessons => [...prevLessons, content]); + } + }; + + const removeLesson = (index) => { + const updatedSelectedLessons = selectedLessons.filter((_, i) => i !== index); + setSelectedLessons(updatedSelectedLessons); + }; + + const addTopic = () => { + setTopics([...topics, '']); + }; + + const removeTopic = (index) => { + const updatedTopics = topics.filter((_, i) => i !== index); + setTopics(updatedTopics); + }; + + const handleTopicChange = (index, value) => { + const updatedTopics = topics.map((topic, i) => i === index ? value : topic); + setTopics(updatedTopics); + }; + + const getContentOptions = () => { + if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) { + return []; + } + + const resourceOptions = resources.map(resource => { + const parsedResource = parseEvent(resource); + return { + label: