├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── .eslintignore
├── .eslintrc.js
├── .github
└── FUNDING.yml
├── .gitignore
├── .gitpod.Dockerfile
├── .gitpod.yml
├── Gitpod.md
├── LICENSE
├── Makefile
├── Procfile
├── Readme.md
├── _docs
├── how
│ └── googleDomainsSetup.md
└── why
│ ├── draft-vs-slate-vs-quill.md
│ └── postgres.md
├── backend
├── afterMiddleware.js
├── api
│ ├── AdminApi
│ │ └── index.js
│ ├── AuthApi
│ │ ├── index.js
│ │ ├── services
│ │ │ ├── github
│ │ │ │ ├── githubFetchAccessToken.js
│ │ │ │ └── githubFetchAuthorizedAccount.js
│ │ │ └── google
│ │ │ │ ├── googleFetchAccessToken.js
│ │ │ │ └── googleFetchAuthorizedAccount.js
│ │ └── test
│ │ │ ├── index.test.js
│ │ │ └── nock
│ │ │ ├── github.js
│ │ │ └── google.js
│ ├── CourseApi
│ │ ├── duplicate.js
│ │ ├── find.js
│ │ ├── getBest4.js
│ │ ├── getMyEverything.js
│ │ ├── getPublic.js
│ │ ├── getRatings.js
│ │ ├── getStudentsStats.js
│ │ ├── index.js
│ │ ├── rate.js
│ │ ├── search.js
│ │ ├── searchCreateEmbeddingsForAllCourses.js
│ │ ├── services
│ │ │ └── getRatingsAndAverageAndOwn.js
│ │ └── updateCoauthors.js
│ ├── CourseCategoryApi
│ │ ├── getAll.js
│ │ └── index.js
│ ├── CourseUserIsLearningApi
│ │ ├── index.js
│ │ ├── resumeLearningCourse.js
│ │ ├── startLearningCourse.js
│ │ └── stopLearningCourse.js
│ ├── FileApi
│ │ └── index.js
│ ├── NotificationApi
│ │ ├── announceNewFeature.js
│ │ ├── getNotificationStatsForUser.js
│ │ ├── getNotificationsForUser.js
│ │ ├── index.js
│ │ ├── markAllNotificationsAsRead.js
│ │ └── markAsReadOrUnread.js
│ ├── PageApi
│ │ ├── Readme.md
│ │ ├── getAllPage.js
│ │ ├── getForCourseActions.js
│ │ ├── getReviewPage.js
│ │ ├── getUserPage.js
│ │ └── index.js
│ ├── ProblemApi
│ │ ├── create.js
│ │ ├── deleteMany.js
│ │ ├── exportToExcel.js
│ │ ├── importFromExcel.js
│ │ ├── index.js
│ │ ├── moveToCourseMany.js
│ │ ├── reorder.js
│ │ ├── services
│ │ │ └── movePuils.js
│ │ └── update.js
│ ├── ProblemUserIsLearningApi
│ │ ├── ignoreAlreadyLearnedProblem.js
│ │ ├── ignoreProblem.js
│ │ ├── index.js
│ │ ├── learnProblem.js
│ │ ├── reviewProblem.js
│ │ ├── services
│ │ │ └── createPuil.js
│ │ └── unlearnUnignoreProblem.js
│ ├── UserApi
│ │ ├── findByString.js
│ │ └── index.js
│ ├── services
│ │ └── getProblemsByCourseId.js
│ └── urls.js
├── beforeMiddleware.js
├── db
│ ├── db_views.sql
│ ├── init.js
│ ├── knex.js
│ ├── migrations
│ │ ├── 1.sql.ran
│ │ ├── 10.sql.ran
│ │ ├── 11.sql.ran
│ │ ├── 12.sql.ran
│ │ ├── 13.sql.ran
│ │ ├── 14.sql.ran
│ │ ├── 15.sql
│ │ ├── 2.sql.ran
│ │ ├── 3.sql.ran
│ │ ├── 4.sql.ran
│ │ ├── 5.sql.ran
│ │ ├── 6.sql.ran
│ │ ├── 7.sql.ran
│ │ ├── 8.sql.ran
│ │ └── 9.sql.ran
│ ├── schema.sql
│ ├── seed.js
│ └── services
│ │ ├── getConnectionString.js
│ │ └── pgOptions.js
├── emails
│ ├── sendWelcomeEmail.js
│ └── sendgrid.js
├── html.js
├── index.js
├── middlewares
│ ├── allowCrossDomain.js
│ ├── auth.js
│ ├── authenticate.js
│ ├── bodyParser.js
│ ├── guard.js
│ ├── handleErrors.js
│ ├── injectResponseTypes.js
│ ├── nocache.js
│ ├── optionalAuthenticate.js
│ ├── stopPropagationForAssets.js
│ └── webpackedFiles.js
├── models
│ ├── CourseCategoryModel
│ │ ├── index.js
│ │ └── insert.js
│ ├── CourseModel
│ │ ├── delete.js
│ │ ├── index.js
│ │ ├── insert.js
│ │ ├── select
│ │ │ ├── index.js
│ │ │ ├── index.test.js
│ │ │ └── services
│ │ │ │ └── wherePublic.js
│ │ └── update.js
│ ├── CourseRatingModel
│ │ ├── delete.js
│ │ ├── index.js
│ │ ├── insert.js
│ │ ├── select.js
│ │ └── update.js
│ ├── CourseUserIsLearningModel
│ │ ├── index.js
│ │ ├── select
│ │ │ └── index.js
│ │ └── update.js
│ ├── NotificationModel
│ │ ├── index.js
│ │ └── insert.js
│ ├── ProblemUserIsLearningModel
│ │ ├── index.js
│ │ └── select.js
│ └── UserModel
│ │ ├── index.js
│ │ ├── insert.js
│ │ ├── select.js
│ │ └── update.js
├── router.js
├── services
│ ├── camelizeDbColumns.js
│ ├── canAccessCourse.js
│ ├── catchAsync.js
│ ├── createEmbedding.js
│ ├── integerizeDbColumns.js
│ ├── requireKeys.js
│ └── uploadFileToAwsS3.js
├── test
│ ├── Readme.md
│ ├── catchingErrorsInRoutes.test.js
│ └── services
│ │ └── Factory.js
└── webpack
│ ├── development.config.js
│ ├── production.config.js
│ └── sharedConfig.js
├── env.example.js
├── frontend
├── api
│ ├── CourseApi.js
│ ├── FileApi.js
│ ├── commonFetch.js
│ ├── index.js
│ └── services
│ │ ├── fetchWrapper.js
│ │ ├── fileFetch.js
│ │ ├── handleErrors.js
│ │ └── hashToQueryString.js
├── appComponents
│ ├── CourseCardLearnReview
│ │ ├── components
│ │ │ └── LearnAndReviewButtons.js
│ │ ├── index.css
│ │ └── index.js
│ ├── CourseCardSimple
│ │ ├── index.css
│ │ └── index.js
│ ├── CourseCategories
│ │ ├── index.css
│ │ └── index.js
│ ├── CourseCategoryFormLine
│ │ ├── components
│ │ │ └── CourseCategorySelect
│ │ │ │ ├── index.js
│ │ │ │ └── index.scss
│ │ └── index.js
│ ├── Footer
│ │ ├── index.css
│ │ └── index.js
│ ├── Header
│ │ ├── components
│ │ │ ├── CoursesDropdown
│ │ │ │ ├── components
│ │ │ │ │ └── CourseCard.js
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ │ ├── CurrentUser
│ │ │ │ ├── components
│ │ │ │ │ └── NotificationsTogglerAndDropdown
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ └── NotificationLi
│ │ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ │ └── index.js
│ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ └── index.js
│ │ │ │ └── index.js
│ │ │ ├── Logo
│ │ │ │ ├── halloween.png
│ │ │ │ ├── hat.svg
│ │ │ │ ├── index.js
│ │ │ │ └── missle.svg
│ │ │ └── SignInLinks.js
│ │ ├── index.css
│ │ └── index.js
│ ├── ListOfCourseCards
│ │ ├── index.css
│ │ └── index.js
│ ├── Main
│ │ ├── download.jpeg
│ │ ├── image-1.jpg
│ │ └── index.js
│ ├── NavigationAdmin.js
│ ├── PageAdmin
│ │ ├── index.css
│ │ └── index.js
│ ├── Readme.md
│ ├── SettingsModal
│ │ ├── index.js
│ │ └── index.scss
│ ├── SignInButtons
│ │ ├── index.css
│ │ └── index.js
│ ├── ThemeToggleButton.js
│ └── UserSelect
│ │ ├── index.js
│ │ └── index.scss
├── components
│ ├── CourseActions
│ │ ├── components
│ │ │ ├── CourseDescriptionAndStats.js
│ │ │ ├── CourseModal
│ │ │ │ ├── components
│ │ │ │ │ ├── TabEditCourseDetails
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ └── index.scss
│ │ │ │ │ ├── TabImportExport
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── SectionExportFlashcards.js
│ │ │ │ │ │ │ └── SectionImportFlashcards.js
│ │ │ │ │ │ ├── images
│ │ │ │ │ │ │ └── exampleOfGoodExcelForImport.png
│ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ └── index.js
│ │ │ │ │ └── TabManage
│ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ └── index.js
│ │ │ │ ├── index.js
│ │ │ │ └── index.scss
│ │ │ ├── CourseStarRating.js
│ │ │ ├── CuilButtons.js
│ │ │ ├── InviteCoauthorModal
│ │ │ │ ├── index.js
│ │ │ │ └── index.scss
│ │ │ ├── MetaTags.js
│ │ │ └── StatsModal
│ │ │ │ ├── index.js
│ │ │ │ └── index.scss
│ │ ├── index.css
│ │ └── index.js
│ ├── Editor
│ │ └── index.js
│ ├── ErrorBoundary.js
│ ├── FetchedSelectDropdown.js
│ ├── Loading
│ │ ├── index.js
│ │ └── requestIcon.svg
│ ├── Pagination
│ │ ├── index.css
│ │ └── index.js
│ ├── Problem
│ │ ├── components
│ │ │ ├── InlinedAnswersEdit.js
│ │ │ ├── InlinedAnswersReview.js
│ │ │ ├── InlinedAnswersShow.js
│ │ │ ├── SeparateAnswerEdit.js
│ │ │ ├── SeparateAnswerReview.js
│ │ │ ├── SeparateAnswerShow.js
│ │ │ └── utils
│ │ │ │ ├── splitAltAnswers.js
│ │ │ │ └── splitAltAnswers.test.js
│ │ └── index.js
│ ├── ProgressBar
│ │ ├── index.css
│ │ └── index.js
│ ├── Rating.js
│ ├── ReadonlyEditor.js
│ ├── Select.js
│ ├── SelectDropdown.js
│ ├── StandardTooltip
│ │ ├── index.js
│ │ └── index.scss
│ ├── StarRating
│ │ ├── index.js
│ │ └── index.scss
│ ├── TabNavigation.js
│ ├── ToggleButton
│ │ ├── index.js
│ │ └── index.scss
│ ├── TogglerAndModal
│ │ ├── index.js
│ │ └── index.scss
│ ├── _standardForm
│ │ ├── EditorTextarea.js
│ │ ├── Select.js
│ │ ├── TextInput.js
│ │ ├── components
│ │ │ └── FormLineLayout.js
│ │ └── index.js
│ ├── withParams.js
│ └── withRouter.js
├── css
│ ├── action.css
│ ├── article-navigation.css
│ ├── bright-theme.css
│ ├── button.css
│ ├── clear-default-css.css
│ ├── container.css
│ ├── focus-styles.css
│ ├── loading.css
│ ├── problem.css
│ ├── quill
│ │ ├── index.css
│ │ ├── placeholder-for-loading-image.css
│ │ ├── ql-toolbar.css
│ │ └── snow-theme.css
│ ├── react-select.css
│ ├── standard-article-formatting.css
│ ├── standard-course-card.css
│ ├── standard-dropdown-with-arrow.css
│ ├── standard-dropdown.css
│ ├── standard-form.css
│ ├── standard-input.css
│ ├── standard-modal.scss
│ ├── standard-navigation_and_courses.scss
│ ├── standard-success-message.css
│ ├── standard-tab-navigation.css
│ ├── standard-table.css
│ ├── standard-title-and-description.css
│ ├── standard-tooltip-dropdown.css
│ ├── standard-tooltip.css
│ ├── tippy-tooltip.css
│ └── variables.css
├── ducks
│ ├── MyDuck.js
│ └── Readme.md
├── fonts
│ └── font-awesome
│ │ ├── fonts
│ │ ├── FontAwesome.otf
│ │ ├── fontawesome-webfont.eot
│ │ ├── fontawesome-webfont.svg
│ │ ├── fontawesome-webfont.ttf
│ │ ├── fontawesome-webfont.woff
│ │ └── fontawesome-webfont.woff2
│ │ └── scss
│ │ ├── _animated.scss
│ │ ├── _bordered-pulled.scss
│ │ ├── _core.scss
│ │ ├── _fixed-width.scss
│ │ ├── _icons.scss
│ │ ├── _larger.scss
│ │ ├── _list.scss
│ │ ├── _mixins.scss
│ │ ├── _path.scss
│ │ ├── _rotated-flipped.scss
│ │ ├── _screen-reader.scss
│ │ ├── _stacked.scss
│ │ ├── _variables.scss
│ │ └── font-awesome.scss
├── images
│ └── closeButton.svg
├── index.css
├── index.html
├── index.js
├── karma.conf.js
├── models
│ ├── CourseCategoryGroupModel.js
│ ├── CourseCategoryModel.js
│ ├── CourseModel.js
│ ├── MyModel.js
│ └── ProblemUserIsLearningModel.js
├── nonWebpackedFiles
│ ├── Readme.md
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── favicon_package_v0.16 (8)
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon.ico
│ │ ├── mstile-150x150.png
│ │ ├── safari-pinned-tab.svg
│ │ └── site.webmanifest
│ ├── mstile-150x150.png
│ ├── safari-pinned-tab.svg
│ ├── safari-pinned-tab.xml
│ └── site.webmanifest
├── pages
│ ├── admin_notifications
│ │ ├── index.css
│ │ └── index.js
│ ├── articles_comparison
│ │ ├── components
│ │ │ ├── Heading.js
│ │ │ ├── LearningAlgorithm.js
│ │ │ ├── QualityOfCourses.js
│ │ │ ├── TypesOfTasks
│ │ │ │ ├── brainscape_1.gif
│ │ │ │ ├── index.js
│ │ │ │ ├── memcode_1_new.gif
│ │ │ │ ├── memcode_1_old.gif
│ │ │ │ ├── memcode_2_new.gif
│ │ │ │ ├── memcode_2_old.gif
│ │ │ │ ├── memrise_1.gif
│ │ │ │ ├── memrise_2.gif
│ │ │ │ ├── quizlet_1.gif
│ │ │ │ ├── quizlet_2.gif
│ │ │ │ └── quizlet_3.gif
│ │ │ ├── UI.js
│ │ │ └── WhenToUseEach.js
│ │ ├── index.css
│ │ └── index.js
│ ├── articles_home
│ │ └── index.js
│ ├── articles_welcome
│ │ ├── components
│ │ │ ├── Courses.js
│ │ │ ├── FakeFlashcards.js
│ │ │ └── Table.js
│ │ ├── images
│ │ │ ├── creation.jpg
│ │ │ ├── darkmode.png
│ │ │ ├── feather.png
│ │ │ ├── heart.png
│ │ │ ├── heartGif.gif
│ │ │ ├── redheart.png
│ │ │ ├── rose.png
│ │ │ ├── waves.jpg
│ │ │ └── wavesOld.jpg
│ │ ├── index.css
│ │ ├── index.js
│ │ └── todo.md
│ ├── contact
│ │ ├── index.css
│ │ └── index.js
│ ├── courses
│ │ ├── components
│ │ │ └── SortBySelect.js
│ │ ├── index.css
│ │ └── index.js
│ ├── courses_id
│ │ ├── components
│ │ │ ├── Cheatsheet
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ │ ├── Instructions
│ │ │ │ ├── components
│ │ │ │ │ ├── HowToCreate
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ ├── memcode_1.gif
│ │ │ │ │ │ ├── memcode_2.gif
│ │ │ │ │ │ └── memcode_2_old.gif
│ │ │ │ │ └── HowToLearn
│ │ │ │ │ │ ├── add_to_learned_courses.png
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ ├── learn.png
│ │ │ │ │ │ └── review.png
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ │ ├── NewProblem.js
│ │ │ ├── OldProblem
│ │ │ │ ├── components
│ │ │ │ │ ├── Checkbox.js
│ │ │ │ │ ├── DeleteFlashcardsModal.js
│ │ │ │ │ └── ExportFlashcardsModal.js
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ │ └── services
│ │ │ │ └── switchType.js
│ │ ├── index.css
│ │ └── index.js
│ ├── courses_id_all_print
│ │ ├── index.css
│ │ └── index.js
│ ├── courses_id_learn
│ │ ├── components
│ │ │ └── Tabs
│ │ │ │ ├── components
│ │ │ │ ├── ProblemWrapper.js
│ │ │ │ ├── TabContent.js
│ │ │ │ └── TabNavigation.js
│ │ │ │ └── index.js
│ │ ├── index.css
│ │ └── index.js
│ ├── courses_id_review
│ │ ├── components
│ │ │ ├── ProblemBeingSolved
│ │ │ │ ├── components
│ │ │ │ │ ├── SeparateAnswerSelfScore
│ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ └── index.js
│ │ │ │ │ └── Subheader
│ │ │ │ │ │ ├── index.css
│ │ │ │ │ │ └── index.js
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ │ └── WhatsNext
│ │ │ │ ├── index.css
│ │ │ │ └── index.js
│ │ ├── duck
│ │ │ ├── actions.js
│ │ │ ├── reducer.js
│ │ │ ├── selectors.js
│ │ │ └── services
│ │ │ │ ├── amountOfAnswerInputsInProblem.js
│ │ │ │ ├── calculateScore.js
│ │ │ │ ├── freshStatusOfSolving.js
│ │ │ │ ├── playLongSound.js
│ │ │ │ └── playShortSound.js
│ │ ├── index.css
│ │ ├── index.js
│ │ ├── real_shimmer.mp3
│ │ ├── real_short.mp3
│ │ ├── shimmer.mp3
│ │ ├── short.mp3
│ │ └── short_shimmer.mp3
│ ├── courses_id_review_print
│ │ ├── index.css
│ │ └── index.js
│ ├── courses_id_story
│ │ └── index.js
│ ├── courses_new
│ │ ├── index.css
│ │ └── index.js
│ ├── home
│ │ ├── index.css
│ │ └── index.js
│ ├── offline_courses
│ │ └── index.js
│ ├── pleaseSignIn
│ │ ├── index.css
│ │ └── index.js
│ ├── profile
│ │ ├── index.css
│ │ └── index.js
│ ├── test
│ │ └── index.js
│ └── users_id
│ │ ├── components
│ │ ├── Courses
│ │ │ ├── components
│ │ │ │ └── ForBeginners.js
│ │ │ ├── index.css
│ │ │ └── index.js
│ │ └── UserInfo.js
│ │ ├── index.css
│ │ └── index.js
├── reducers
│ ├── Authentication.js
│ ├── Readme.md
│ └── index.js
├── router.js
├── service-worker.js
├── services
│ ├── Roles.js
│ ├── SpeImmutable.js
│ ├── Urls.js
│ ├── capitalize.js
│ ├── contentObjectToString.js
│ ├── contentStringToJsx.js
│ ├── createAndDownloadExcelFile.js
│ ├── customPropTypes.js
│ ├── disableOnSpeRequest.js
│ ├── eachSlice.js
│ ├── eachSlice.test.js
│ ├── fromDataUrlToBlob.js
│ ├── fromFileToDataUrl.js
│ ├── hideOnEsc.js
│ ├── humanizePostgresInterval.js
│ ├── ifPositivePostgresInterval.js
│ ├── injectFromOldToNewIndex.js
│ ├── isProblemContentTheSame.js
│ ├── jwtToUserObject.js
│ ├── onEnters.js
│ ├── orFalse.js
│ ├── playSound.js
│ ├── preloadImage.js
│ ├── preloadImages.js
│ ├── problemContentToTextarea.js
│ ├── quill
│ │ ├── blots
│ │ │ ├── Answer.js
│ │ │ └── LoadingImageBlot.js
│ │ ├── handlers
│ │ │ ├── codeBlockHandler.js
│ │ │ ├── codeLineHandler.js
│ │ │ ├── dropOrPasteImageHandler.js
│ │ │ ├── formulaHandler.js
│ │ │ ├── markAsAnswerHandler.js
│ │ │ ├── quoteHandler.js
│ │ │ ├── services
│ │ │ │ ├── insertImageWithDataUrlSrc.js
│ │ │ │ └── placeholdAndCreateImage.js
│ │ │ ├── subScriptHandler.js
│ │ │ ├── superScriptHandler.js
│ │ │ └── uploadImageHandler.js
│ │ ├── moduleDropOrPasteImage.js
│ │ ├── msWordPasteMatchers.js
│ │ ├── registerBlots.js
│ │ ├── registerModules.js
│ │ └── standardToolbarContainer.js
│ ├── randomSample.js
│ ├── readUploadedExcelFile.js
│ ├── spe.js
│ ├── speCreator.js
│ ├── stripTags.js
│ ├── surroundSelectionWithString.js
│ ├── surroundSelectionWithString.test.js
│ └── toArray.js
├── store.js
└── webpack
│ ├── development.config.js
│ ├── production.config.js
│ └── sharedConfig.js
├── makefile_win.make
├── package-lock.json
├── package.json
└── services
├── getNextScore.js
├── getNextScore.test.js
├── initialScore.js
└── isPatreonUsername.js
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use official Node.js image as the base image
2 | FROM node:20
3 |
4 | # Set the working directory inside the container
5 | WORKDIR /usr/src/app
6 |
7 | # Install PostgreSQL and the PostgreSQL client
8 | RUN apt-get update && apt-get install -y \
9 | postgresql \
10 | postgresql-client \
11 | && rm -rf /var/lib/apt/lists/*
12 |
13 | # Copy package.json and package-lock.json
14 | COPY package*.json ./
15 |
16 | # Install dependencies
17 | RUN npm install
18 |
19 | # Copy the rest of the application code, including the .env file
20 | COPY . .
21 |
22 | # Copy environment variables
23 | COPY env.example.js env.js
24 |
25 | # Expose the port the app runs on
26 | EXPOSE 3000
27 |
28 | # Set up environment variables
29 | ENV NODE_ENV=development
30 | ENV PGPASSWORD=postgres
31 |
32 | # Initialize PostgreSQL database and user
33 | RUN service postgresql start && \
34 | su - postgres -c "psql -c \"CREATE ROLE postgres WITH LOGIN PASSWORD 'postgres';\"" && \
35 | su - postgres -c "psql -c \"ALTER ROLE postgres WITH SUPERUSER;\"" && \
36 | su - postgres -c "createdb memcode"
37 |
38 | # Start the app
39 | CMD ["bash", "-c", "service postgresql start && make db-reset && make all"]
40 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Memcode Dev Environment",
3 | "dockerFile": "Dockerfile",
4 | "appPort": [3000],
5 | "postCreateCommand": "npm install && make db-reset",
6 | "settings": {
7 | "terminal.integrated.shell.linux": "/bin/bash"
8 | },
9 | "extensions": [
10 | "dbaeumer.vscode-eslint"
11 | ],
12 | "remoteUser": "vscode",
13 | "customizations": {
14 | "vscode": {
15 | "tasks": [
16 | {
17 | "label": "Backend Webpack",
18 | "type": "shell",
19 | "command": "make backend-webpack",
20 | "problemMatcher": [],
21 | "isBackground": false
22 | },
23 | {
24 | "label": "Frontend Webpack",
25 | "type": "shell",
26 | "command": "make frontend-webpack",
27 | "problemMatcher": [],
28 | "isBackground": false
29 | },
30 | {
31 | "label": "Start Server",
32 | "type": "shell",
33 | "command": "make start",
34 | "problemMatcher": []
35 | }
36 | ]
37 | }
38 | },
39 | "postStartCommand": "code --wait --new-window && code --new-window --wait .vscode/tasks.json && code --new-window"
40 | }
41 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | lakesare.secrets.js
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: memcode
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 |
3 | # we could rename this folder to /webpackedFiles at some point
4 | /backend/webpacked/**
5 | /frontend/webpackedFiles/*
6 |
7 | /env.js
8 | npm-debug.log
9 | .DS_Store
10 |
11 | lakesare.secrets.js
12 | /meresei/**
13 |
--------------------------------------------------------------------------------
/.gitpod.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM gitpod/workspace-postgres
2 |
3 | USER gitpod
4 |
5 | # Install custom tools, runtime, etc. using apt-get
6 | # For example, the command below would install "bastet" - a command line tetris clone:
7 | #
8 | # RUN sudo apt-get -q update && # sudo apt-get install -yq bastet && # sudo rm -rf /var/lib/apt/lists/*
9 | #
10 | # More information: https://www.gitpod.io/docs/config-docker/
11 |
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | tasks:
2 | - init: nvm install 8.4 && npm install && psql -v database=memcode -U gitpod -f backend/db/schema.sql && make all &
3 | image:
4 | file: .gitpod.Dockerfile
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Evgenia Karunus
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node backend/webpacked/index.js
--------------------------------------------------------------------------------
/_docs/how/googleDomainsSetup.md:
--------------------------------------------------------------------------------
1 | ___How to make www.memcode.com (simple memcode.com already works) redirect to https://www.memcode.com?
2 | Google Domains support told me it must be done specifically via the heroku server, and heroku has this page: https://help.heroku.com/J2R1S4T8/can-heroku-force-an-application-to-use-ssl-tls.
3 |
4 | ___Is it possible to have everything redirect to https://memcode.com, without www?
5 | Google Domains support: `Jerry 10:14PM - Well, unfortunately, Heroku needs www to properly set up the pointing. On the other hand, you may ask Heroku if we can just point it to your website using an IP so we can only set up the A records.`,
6 | so seemingly it's not possible.
7 |
--------------------------------------------------------------------------------
/_docs/why/draft-vs-slate-vs-quill.md:
--------------------------------------------------------------------------------
1 | https://docs.slatejs.org/walkthroughs/installing-slate.html
2 | http://slatejs.org/#/hovering-menu
3 | https://github.com/ianstormtaylor/slate/blob/master/examples/rich-text/index.js
4 | https://quilljs.com/guides/building-a-custom-module/
5 |
--------------------------------------------------------------------------------
/_docs/why/postgres.md:
--------------------------------------------------------------------------------
1 | ___Why are column names underscored?
2 | Postgres lowercases all column names, and therefore returns { oauthid: 1 } from 'select * from' query, and I need oauthId.
3 |
4 | ___Why do we use 'timestamptz' instead of 'timestamp's?
5 | Knex does not work well with 'timestamp's.
6 | We should use 'timestamptz' - the timezone-aware type.
7 | (https://github.com/knex/knex/issues/2094#issuecomment-305489801)
8 | timestamptz always stores the date in utc, - and it converts it on its own.
9 |
--------------------------------------------------------------------------------
/backend/afterMiddleware.js:
--------------------------------------------------------------------------------
1 | import router from '~/router';
2 | import nocache from '~/middlewares/nocache';
3 |
4 | import html from '~/html';
5 | router.get('*', nocache(), (request, response) => response.send(html));
6 |
7 | import handleErrors from '~/middlewares/handleErrors';
8 | router.use(handleErrors);
9 |
10 | // import router from '~/router';
11 | // const path = require('path');
12 | //
13 | // router.get('*', (req, res) => {
14 | // res.sendFile(path.join(__dirname, '../../frontend/webpackedFiles/index.html'));
15 | // });
16 | //
17 | // import handleErrors from '~/middlewares/handleErrors';
18 | // router.use(handleErrors);
19 |
--------------------------------------------------------------------------------
/backend/api/AdminApi/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | const router = express.Router();
3 |
4 | export default router;
5 |
--------------------------------------------------------------------------------
/backend/api/AuthApi/services/github/githubFetchAccessToken.js:
--------------------------------------------------------------------------------
1 | // getting access token by sending github authorization code that will prove to github that we are the application (client_id, client_secret) that user gave access to
2 | const githubFetchAccessToken = async (oauthId, oauthSecret, code) => {
3 | // 'access_token=0bc4d5757978a90d8e9bc96fac795c876179f2ba&scope=&token_type=bearer'
4 | const stringWithAccessToken = await
5 | fetch('https://github.com/login/oauth/access_token', {
6 | method: 'POST',
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | 'Accept': 'application/json'
10 | },
11 | body: JSON.stringify({
12 | client_id: oauthId,
13 | client_secret: oauthSecret,
14 | code,
15 | scope: "user:email"
16 | }),
17 | })
18 | .then((response) => response.json());
19 |
20 | // {"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.",
21 | if (stringWithAccessToken.error) {
22 | return Promise.reject(stringWithAccessToken.error_description);
23 | } else {
24 | const accessToken = stringWithAccessToken.access_token;
25 | return accessToken;
26 | }
27 | };
28 |
29 | export { githubFetchAccessToken };
30 |
--------------------------------------------------------------------------------
/backend/api/AuthApi/services/google/googleFetchAccessToken.js:
--------------------------------------------------------------------------------
1 | import { URLSearchParams } from 'url';
2 |
3 | // getting access token by sending github authorization code that will prove to github that we are the application (client_id, client_secret) that user gave access to
4 | const googleFetchAccessToken = async (oauthId, oauthSecret, code) => {
5 | const data = new URLSearchParams();
6 | data.append('client_id', oauthId);
7 | data.append('client_secret', oauthSecret);
8 | data.append('code', code);
9 | data.append('redirect_uri', process.env['GOOGLE_OAUTH_CALLBACK']);
10 | data.append('grant_type', 'authorization_code');
11 |
12 | const stringWithAccessToken = await
13 | fetch('https://www.googleapis.com/oauth2/v4/token', {
14 | method: 'POST',
15 | body: data
16 | })
17 | .then((response) => response.json());
18 |
19 | // {"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.",
20 | if (stringWithAccessToken.error) {
21 | return Promise.reject(stringWithAccessToken.error_description);
22 | } else {
23 | const accessToken = stringWithAccessToken.access_token;
24 | return accessToken;
25 | }
26 | };
27 |
28 | export { googleFetchAccessToken };
29 |
--------------------------------------------------------------------------------
/backend/api/AuthApi/services/google/googleFetchAuthorizedAccount.js:
--------------------------------------------------------------------------------
1 | // fetching our profile info signed in as a user (access token)
2 | const googleFetchAuthorizedAccount = (accessToken) =>
3 | fetch('https://www.googleapis.com/userinfo/v2/me', {
4 | headers: {
5 | Authorization: `Bearer ${accessToken}`,
6 | Accept: 'application/json'
7 | }
8 | }).then((response) => (
9 | response.ok ?
10 | response.json() :
11 | response.json()
12 | .then(Promise.reject)
13 | ));
14 |
15 | export { googleFetchAuthorizedAccount };
16 |
--------------------------------------------------------------------------------
/backend/api/CourseApi/find.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const rate = async (request, response) => {
4 | const searchString = request.body['searchString'];
5 |
6 | // const existingRatingSql = await knex('courseRating').where({ userId, courseId });
7 |
8 | const courses = await knex('course').where('title', 'ilike', '%' + searchString + '%');
9 |
10 | response.success(courses);
11 | };
12 |
13 | export default rate;
14 |
--------------------------------------------------------------------------------
/backend/api/CourseApi/getBest4.js:
--------------------------------------------------------------------------------
1 | import CourseModel from '~/models/CourseModel';
2 |
3 | const getBest4 = async (request, response) => {
4 | const courses = await CourseModel.select.allPublic({
5 | limit: 4,
6 | offset: 0,
7 | customWhere: 'AND course.id IN (1492, 944, 1490, 632)'
8 | });
9 |
10 | response.status(200).json({ courses });
11 | };
12 |
13 | export default getBest4;
14 |
--------------------------------------------------------------------------------
/backend/api/CourseApi/getRatings.js:
--------------------------------------------------------------------------------
1 | import getRatingsAndAverageAndOwn from './services/getRatingsAndAverageAndOwn';
2 |
3 | const getRatings = async (request, response) => {
4 | const userId = request.currentUser ? request.currentUser.id : null;
5 | const courseId = request.body['courseId'];
6 |
7 | const obj = await getRatingsAndAverageAndOwn(courseId, userId);
8 |
9 | response.success(obj);
10 | };
11 |
12 | export default getRatings;
13 |
--------------------------------------------------------------------------------
/backend/api/CourseApi/rate.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 | import getRatingsAndAverageAndOwn from './services/getRatingsAndAverageAndOwn';
4 | import NotificationModel from '~/models/NotificationModel';
5 |
6 | const rate = auth(async (request, response) => {
7 | const userId = request.currentUser.id;
8 | const courseId = request.body['courseId'];
9 | const rating = request.body['rating'];
10 |
11 | const existingRatingSql = await knex('courseRating').where({ userId, courseId });
12 | const existingRating = existingRatingSql[0];
13 |
14 | if (existingRating) {
15 | await knex('courseRating').where({ userId, courseId }).update({ rating });
16 | } else {
17 | await knex('courseRating').insert({ userId, courseId, rating });
18 | }
19 |
20 | await NotificationModel.insert.someone_rated_your_course({ raterId: userId, courseId, rating });
21 |
22 | const dto = await getRatingsAndAverageAndOwn(courseId, userId);
23 |
24 | response.success(dto);
25 | });
26 |
27 | export default rate;
28 |
--------------------------------------------------------------------------------
/backend/api/CourseApi/services/getRatingsAndAverageAndOwn.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const getRatingsAndAverageAndOwn = async (courseId, currentUserId) => {
4 | const ratings = await knex('courseRating').where({ courseId });
5 |
6 | const averageRatingSql = await knex('courseRating')
7 | .select(knex.raw('ROUND(AVG(rating), 1) AS average_rating'))
8 | .where({ courseId });
9 | const averageRating = averageRatingSql[0].averageRating;
10 |
11 | let ownRating;
12 | if (currentUserId) {
13 | const rating = await knex('courseRating').where({ userId: currentUserId, courseId });
14 | ownRating = rating[0] ? rating[0].rating : null;
15 | } else {
16 | ownRating = null;
17 | }
18 |
19 | return {
20 | ratings,
21 | averageRating,
22 | ownRating
23 | };
24 | };
25 |
26 | export default getRatingsAndAverageAndOwn;
27 |
--------------------------------------------------------------------------------
/backend/api/CourseCategoryApi/getAll.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import catchAsync from '~/services/catchAsync';
3 |
4 | const getAll = catchAsync(async (request, response) => {
5 | const courseCategories = await knex('courseCategory');
6 | const courseCategoryGroups = await knex('courseCategoryGroup');
7 |
8 | response.success({ courseCategories, courseCategoryGroups });
9 | });
10 |
11 | export default getAll;
12 |
--------------------------------------------------------------------------------
/backend/api/CourseCategoryApi/index.js:
--------------------------------------------------------------------------------
1 | import getAll from './getAll';
2 |
3 | export default {
4 | getAll,
5 | };
6 |
--------------------------------------------------------------------------------
/backend/api/CourseUserIsLearningApi/index.js:
--------------------------------------------------------------------------------
1 | import startLearningCourse from './startLearningCourse';
2 | import stopLearningCourse from './stopLearningCourse';
3 | import resumeLearningCourse from './resumeLearningCourse';
4 |
5 | export default {
6 | startLearningCourse,
7 | stopLearningCourse,
8 | resumeLearningCourse
9 | };
10 |
--------------------------------------------------------------------------------
/backend/api/CourseUserIsLearningApi/resumeLearningCourse.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const resumeLearningCourse = auth(async (request, response) => {
5 | const courseId = request.body['courseId'];
6 | const currentUser = request.currentUser;
7 |
8 | const courseUserIsLearning = (await knex('courseUserIsLearning')
9 | .where({ userId: currentUser.id, courseId })
10 | .update({ active: true })
11 | .returning('*')
12 | )[0];
13 |
14 | response.success(courseUserIsLearning);
15 | });
16 |
17 | export default resumeLearningCourse;
18 |
--------------------------------------------------------------------------------
/backend/api/CourseUserIsLearningApi/startLearningCourse.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 | import NotificationModel from '~/models/NotificationModel';
4 |
5 | const startLearningCourse = auth(async (request, response) => {
6 | const courseId = request.body['courseId'];
7 | const currentUser = request.currentUser;
8 |
9 | const newCuil = (await knex('courseUserIsLearning')
10 | .insert({
11 | courseId,
12 | userId: currentUser.id,
13 | active: true
14 | })
15 | .returning('*')
16 | )[0];
17 |
18 | const course = (await knex('course').where({ id: courseId }))[0];
19 | const authorId = course.userId;
20 |
21 | if (currentUser.id !== authorId) {
22 | // send author a notification that someone started learning their course!
23 | await NotificationModel.insert.someone_started_learning_your_course({ learner: currentUser, course });
24 | }
25 |
26 | response.success(newCuil);
27 | });
28 |
29 | export default startLearningCourse;
30 |
31 |
--------------------------------------------------------------------------------
/backend/api/CourseUserIsLearningApi/stopLearningCourse.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const stopLearningCourse = auth(async (request, response) => {
5 | const courseId = request.body['courseId'];
6 | const currentUser = request.currentUser;
7 |
8 | const courseUserIsLearning = (await knex('courseUserIsLearning')
9 | .where({ userId: currentUser.id, courseId })
10 | .update({ active: false })
11 | .returning('*')
12 | )[0];
13 |
14 | response.success(courseUserIsLearning);
15 | });
16 |
17 | export default stopLearningCourse;
18 |
--------------------------------------------------------------------------------
/backend/api/FileApi/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | const router = express.Router();
3 |
4 | import uploadFileToAwsS3 from '~/services/uploadFileToAwsS3';
5 |
6 | router.post('/upload', uploadFileToAwsS3.single('file'), (request, response) =>
7 | response.status(200).json({ url: request.file.location })
8 | );
9 |
10 | export default router;
11 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/announceNewFeature.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const announceNewFeature = async (request, response) => {
4 | const type = request.body['type'];
5 | const content = request.body['content'];
6 |
7 | const users = await knex('user');
8 |
9 | await knex.transaction((trx) =>
10 | Promise.all(users.map((user) =>
11 | trx()
12 | .insert({ type, content, ifRead: false, userId: user.id })
13 | .into('notification')
14 | ))
15 | );
16 |
17 | response.success({ message: `${users.length} users are notified!` });
18 | };
19 |
20 |
21 | export default announceNewFeature;
22 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/getNotificationStatsForUser.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | // params = { userId }
4 | const getNotificationStatsForUser = async (request, response) => {
5 | const userId = request.body.userId;
6 |
7 | const allNotifications = await knex('notification').where({ userId });
8 | const unreadNotifications = await knex('notification').where({ userId, ifRead: false });
9 |
10 | response.success({
11 | amountOfAllNotifications: allNotifications.length,
12 | amountOfUnreadNotifications: unreadNotifications.length
13 | });
14 | };
15 |
16 | export default getNotificationStatsForUser;
17 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/getNotificationsForUser.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const getNotificationsForUser = async (request, response) => {
4 | const userId = request.body['userId'];
5 | const limit = request.body['limit'];
6 | const offset = request.body['offset'];
7 |
8 | const notifications = await knex('notification')
9 | .select(knex.raw("*, (created_at - now()) AS created_at_diff_from_now"))
10 | .where({ userId })
11 | .offset(offset)
12 | .limit(limit)
13 | .orderBy('createdAt', 'desc');
14 |
15 | response.success(notifications);
16 | };
17 |
18 | export default getNotificationsForUser;
19 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/index.js:
--------------------------------------------------------------------------------
1 | import announceNewFeature from './announceNewFeature';
2 | import getNotificationsForUser from './getNotificationsForUser';
3 | import getNotificationStatsForUser from './getNotificationStatsForUser';
4 | import markAllNotificationsAsRead from './markAllNotificationsAsRead';
5 | import markAsReadOrUnread from './markAsReadOrUnread';
6 |
7 | export default {
8 | announceNewFeature,
9 | getNotificationsForUser,
10 | getNotificationStatsForUser,
11 | markAllNotificationsAsRead,
12 | markAsReadOrUnread
13 | };
14 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/markAllNotificationsAsRead.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const markAllNotificationsAsRead = async (request, response) => {
4 | await knex('notification')
5 | .where({ userId: request.body['userId'], ifRead: false })
6 | .update({ ifRead: true });
7 |
8 | response.success();
9 | };
10 |
11 | export default markAllNotificationsAsRead;
12 |
--------------------------------------------------------------------------------
/backend/api/NotificationApi/markAsReadOrUnread.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const markAsReadOrUnread = async (request, response) => {
4 | const id = request.body['id'];
5 | const ifRead = request.body['ifRead'];
6 |
7 | await knex('notification')
8 | .where({ id })
9 | .update({ ifRead });
10 |
11 | response.success();
12 | };
13 |
14 | export default markAsReadOrUnread;
15 |
--------------------------------------------------------------------------------
/backend/api/PageApi/Readme.md:
--------------------------------------------------------------------------------
1 | We are puting all per-page get routes here, because they don't really tend to belong to certain /components.
2 |
3 | For the single page we may want to fetch many things, for example: course, courseUserIsLearning, and corresponding problems.
4 |
5 | We will only want GET routes here, as deletes and updates tend to only concern one component at a time.
6 |
--------------------------------------------------------------------------------
/backend/api/PageApi/getAllPage.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import canAccessCourse from '~/services/canAccessCourse';
3 | import courseUserIsLearningModel from '~/models/CourseUserIsLearningModel/select/index';
4 |
5 | const cantAccessError = "Sorry, this course is private. Only the author and coauthors and can access it.";
6 |
7 | const getAllPage = async (request, response) => {
8 | const courseId = request.body['courseId'];
9 |
10 | if (!(await canAccessCourse(courseId, request.currentUser))) {
11 | return response.error(cantAccessError);
12 | }
13 |
14 | const courseUserIsLearning = (await knex('courseUserIsLearning')
15 | .where({ courseId, userId: request.currentUser.id }))[0];
16 |
17 | const problems = await courseUserIsLearningModel.selectAll(courseUserIsLearning.id);
18 |
19 | response.success({ courseUserIsLearning, problems });
20 |
21 | };
22 |
23 | export default getAllPage;
24 |
--------------------------------------------------------------------------------
/backend/api/PageApi/getReviewPage.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import canAccessCourse from '~/services/canAccessCourse';
3 | import courseUserIsLearningModel from '~/models/CourseUserIsLearningModel/select/index';
4 |
5 | const cantAccessError = "Sorry, this course is private. Only the author and coauthors and can access it.";
6 |
7 | const getReviewPage = async (request, response) => {
8 | const courseId = request.body['courseId'];
9 |
10 | if (!(await canAccessCourse(courseId, request.currentUser))) {
11 | return response.error(cantAccessError);
12 | }
13 |
14 | const courseUserIsLearning = (await knex('courseUserIsLearning')
15 | .where({ courseId, userId: request.currentUser.id }))[0];
16 |
17 | const problems = await courseUserIsLearningModel.selectReview(courseUserIsLearning.id);
18 |
19 | response.success({ courseUserIsLearning, problems });
20 |
21 | };
22 |
23 | export default getReviewPage;
24 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/create.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const create = auth(async (request, response) => {
5 | const problem = request.body['problem'];
6 |
7 | const createdProblem = (await knex('problem').insert(problem).returning('*'))[0];
8 |
9 | response.success(createdProblem);
10 | });
11 |
12 | export default create;
13 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/deleteMany.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const deleteMany = async (request, response) => {
4 | const ids = request.body['ids'];
5 |
6 | await knex('problem')
7 | .whereIn('id', ids)
8 | .del();
9 |
10 | response.success();
11 | };
12 |
13 | export default deleteMany;
14 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/exportToExcel.js:
--------------------------------------------------------------------------------
1 | import getProblemsByCourseId from '~/api/services/getProblemsByCourseId';
2 |
3 | const exportToExcel = async (request, response) => {
4 | const courseId = request.body['courseId'];
5 | const problems = await getProblemsByCourseId(courseId);
6 | response.success(problems);
7 | };
8 |
9 | export default exportToExcel;
10 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/importFromExcel.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 | // import knex from '~/db/knex';
3 |
4 | const importFromExcel = async (request, response) => {
5 | const courseId = request.body['courseId'];
6 | const problems = request.body['problems'];
7 |
8 | const arrayOfNulls = await db.tx((transaction) => {
9 | const queries = problems.map((problem, index) => {
10 | transaction.none(
11 | "INSERT INTO problem (type, content, course_id, created_at) VALUES (${type}, ${content}, ${courseId}, now())",
12 | {
13 | type: problem.type,
14 | content: problem.content,
15 | position: index + 1,
16 | courseId
17 | }
18 | );
19 | });
20 | return transaction.batch(queries);
21 | });
22 |
23 | response.success({ amountOfCreatedProblems: arrayOfNulls.length });
24 | };
25 |
26 | export default importFromExcel;
27 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/index.js:
--------------------------------------------------------------------------------
1 | import create from './create';
2 | import deleteMany from './deleteMany';
3 | import exportToExcel from './exportToExcel';
4 | import importFromExcel from './importFromExcel';
5 | import moveToCourseMany from './moveToCourseMany';
6 | import reorder from './reorder';
7 | import update from './update';
8 |
9 | export default {
10 | create,
11 | deleteMany,
12 | exportToExcel,
13 | importFromExcel,
14 | moveToCourseMany,
15 | reorder,
16 | update
17 | };
18 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/moveToCourseMany.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import movePuils from './services/movePuils';
3 |
4 | const moveToCourseMany = async (request, response) => {
5 | const problemIds = request.body['problemIds'];
6 | const courseId = request.body['courseId'];
7 |
8 | // 1. Find the flashcards to insert
9 | const flashcards = await knex('problem').whereIn('id', problemIds)
10 | .map((flashcard) => ({
11 | type: flashcard.type,
12 | content: flashcard.content,
13 | courseId
14 | }));
15 |
16 | // 2. Insert the flashcards into the new course
17 | const insertedProblems = await knex('problem').insert(flashcards).returning('*');
18 |
19 | //2.1 Move the problems user is learning to new course as well (needs to be done before deletion to avoid constraint violation)
20 | await movePuils(problemIds, insertedProblems, courseId);
21 |
22 | // 3. Delete the flashcards from the original course
23 | await knex('problem').whereIn('id', problemIds).del();
24 |
25 | response.success();
26 | };
27 |
28 | export default moveToCourseMany;
29 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/reorder.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const reorder = auth(async (request, response) => {
5 | const idOrderMap = request.body;
6 |
7 | const promises = idOrderMap.map(({ id, position }) =>
8 | knex('problem').where({ id }).update({ position })
9 | );
10 |
11 | await Promise.all(promises);
12 |
13 | response.success();
14 | });
15 |
16 | export default reorder;
17 |
--------------------------------------------------------------------------------
/backend/api/ProblemApi/update.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const update = auth(async (request, response) => {
5 | const id = request.body['id'];
6 | const problem = request.body['problem'];
7 |
8 | await knex('problem').where({ id }).update(problem);
9 |
10 | response.success();
11 | });
12 |
13 | export default update;
14 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/ignoreAlreadyLearnedProblem.js:
--------------------------------------------------------------------------------
1 | import guard from '~/middlewares/guard';
2 | import knex from '~/db/knex';
3 |
4 | const ignoreAlreadyLearnedProblem = guard((r) => ['byCuilId', r.body['cuilId']])(
5 | async (request, response) => {
6 | const problemId = request.body['problemId'];
7 | const courseUserIsLearningId = request.body['cuilId'];
8 |
9 | await knex('problemUserIsLearning')
10 | .where({ problemId, courseUserIsLearningId })
11 | .update({ ifIgnored: true })
12 |
13 | response.success();
14 | }
15 | );
16 |
17 | export default ignoreAlreadyLearnedProblem;
18 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/ignoreProblem.js:
--------------------------------------------------------------------------------
1 | import auth from '~/middlewares/auth';
2 | import createPuil from './services/createPuil';
3 |
4 | const ignoreProblem = auth(async (request, response) => {
5 | const problemId = request.body['problemId'];
6 | const userId = request.currentUser.id;
7 |
8 | const ignoredPuil = await createPuil(problemId, userId, { ifIgnored: true });
9 |
10 | response.success(ignoredPuil);
11 | });
12 |
13 | export default ignoreProblem;
14 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/index.js:
--------------------------------------------------------------------------------
1 | import ignoreProblem from './ignoreProblem';
2 | import learnProblem from './learnProblem';
3 | import reviewProblem from './reviewProblem';
4 | import unlearnUnignoreProblem from './unlearnUnignoreProblem';
5 | import ignoreAlreadyLearnedProblem from './ignoreAlreadyLearnedProblem';
6 |
7 | export default {
8 | ignoreProblem,
9 | learnProblem,
10 | reviewProblem,
11 | unlearnUnignoreProblem,
12 | ignoreAlreadyLearnedProblem
13 | };
14 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/learnProblem.js:
--------------------------------------------------------------------------------
1 | import auth from '~/middlewares/auth';
2 | import createPuil from './services/createPuil';
3 |
4 | const learnProblem = auth(async (request, response) => {
5 | const problemId = request.body['problemId'];
6 | const userId = request.currentUser.id;
7 |
8 | const createdPuil = await createPuil(problemId, userId, { ifIgnored: false });
9 |
10 | response.success(createdPuil);
11 | });
12 |
13 | export default learnProblem;
14 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/reviewProblem.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import dayjs from 'dayjs';
3 |
4 | import guard from '~/middlewares/guard';
5 | import getNextScore from '~/../services/getNextScore';
6 |
7 | const reviewProblem = guard((r) => ['byCuilId', r.body['id']])(async (request, response) => {
8 | const courseUserIsLearningId = request.body['id'];
9 | const problemId = request.body['problemId'];
10 | const performanceRating = request.body['performanceRating'];
11 |
12 | const puil = (await knex('problemUserIsLearning').where({ courseUserIsLearningId, problemId }))[0];
13 | const nextScore = getNextScore(puil.easiness, puil.consecutiveCorrectAnswers, performanceRating);
14 | const now = dayjs();
15 | const updatedPuil = (await knex('problemUserIsLearning')
16 | .where({ id: puil.id })
17 | .update({
18 | easiness: nextScore.easiness,
19 | consecutiveCorrectAnswers: nextScore.consecutiveCorrectAnswers,
20 | nextDueDate: now.add(nextScore.msToNextReview, 'ms').format(),
21 | lastReviewedAt: now.format()
22 | })
23 | .returning('*'))[0];
24 | response.success(updatedPuil);
25 | });
26 |
27 | export default reviewProblem;
28 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/services/createPuil.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import dayjs from 'dayjs';
3 | import initialScore from '~/../services/initialScore';
4 |
5 | const createPuil = async (problemId, userId, { ifIgnored }) => {
6 | const problem = (await knex('problem').where({ id: problemId }))[0];
7 | const courseUserIsLearningId = (await knex('courseUserIsLearning')
8 | .where({ courseId: problem.courseId, userId })
9 | )[0].id;
10 |
11 | const createdPuil = (await knex('problemUserIsLearning')
12 | .insert({
13 | easiness: initialScore().easiness,
14 | consecutiveCorrectAnswers: initialScore().consecutiveCorrectAnswers,
15 | ifIgnored,
16 | nextDueDate: dayjs().format(),
17 | courseUserIsLearningId,
18 | problemId,
19 | })
20 | .returning('*')
21 | )[0];
22 |
23 | return createdPuil;
24 | };
25 |
26 | export default createPuil;
27 |
--------------------------------------------------------------------------------
/backend/api/ProblemUserIsLearningApi/unlearnUnignoreProblem.js:
--------------------------------------------------------------------------------
1 | import guard from '~/middlewares/guard';
2 | import knex from '~/db/knex';
3 |
4 | // Unignore flashcard, or unlearn flashcard
5 | const unlearnUnignoreProblem = guard((r) => ['byPuilId', r.body['id']])(
6 | async (request, response) => {
7 | const puilId = request.body['id'];
8 | await knex('problemUserIsLearning').where({ id: puilId }).del();
9 | response.success();
10 | }
11 | );
12 |
13 | export default unlearnUnignoreProblem;
14 |
--------------------------------------------------------------------------------
/backend/api/UserApi/findByString.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const findByString = async (request, response) => {
4 | const searchString = request.body['searchString'];
5 |
6 | const users = await knex('user')
7 | .select('id', 'username', 'avatar_url', 'created_at')
8 | .where('username', 'ilike', `%${searchString}%`)
9 | .orWhere('email', 'ilike', `%${searchString}%`)
10 | .limit(30);
11 |
12 | response.success(users);
13 | };
14 |
15 | export default findByString;
16 |
--------------------------------------------------------------------------------
/backend/api/UserApi/index.js:
--------------------------------------------------------------------------------
1 | import findByString from './findByString';
2 |
3 | export default {
4 | findByString
5 | };
6 |
--------------------------------------------------------------------------------
/backend/api/services/getProblemsByCourseId.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const getProblemsByCourseId = (courseId) =>
4 | knex('problem').where({ course_id: courseId })
5 | // Put position-0 last (because it means they were created after the latest reordering!) (https://stackoverflow.com/a/3130216/3192470)
6 | .orderByRaw('position=0')
7 | .orderBy('position')
8 | .orderBy('createdAt', 'asc');
9 |
10 | export default getProblemsByCourseId;
11 |
--------------------------------------------------------------------------------
/backend/beforeMiddleware.js:
--------------------------------------------------------------------------------
1 | import router from '~/router';
2 |
3 | // only in NODE_ENV=PRODUCTION - this will redirect all http requests to https
4 | import sslRedirect from 'heroku-ssl-redirect';
5 | router.use(sslRedirect());
6 |
7 | import allowCrossDomain from '~/middlewares/allowCrossDomain';
8 | router.use(allowCrossDomain);
9 |
10 | import stopPropagationForAssets from '~/middlewares/stopPropagationForAssets';
11 | router.use(stopPropagationForAssets);
12 |
13 | import bodyParser from '~/middlewares/bodyParser';
14 | router.use(bodyParser);
15 |
16 | import webpackedFiles from '~/middlewares/webpackedFiles';
17 | router.use(webpackedFiles);
18 |
19 | import optionalAuthenticate from '~/middlewares/optionalAuthenticate';
20 | router.use(optionalAuthenticate);
21 |
22 | import injectResponseTypes from '~/middlewares/injectResponseTypes';
23 | router.use(injectResponseTypes);
24 |
--------------------------------------------------------------------------------
/backend/db/init.js:
--------------------------------------------------------------------------------
1 | import * as pgPromise from 'pg-promise';
2 |
3 | import pgOptions from './services/pgOptions';
4 | import getConnectionString from './services/getConnectionString';
5 |
6 | const pgPackage = pgPromise.default(pgOptions);
7 |
8 | const db = pgPackage(getConnectionString());
9 |
10 | db.connect()
11 | .then((obj) => {
12 | obj.done();
13 | })
14 | .catch((error) => {
15 | console.log("ERROR:", error.message || error);
16 | });
17 |
18 | export default db;
19 |
--------------------------------------------------------------------------------
/backend/db/knex.js:
--------------------------------------------------------------------------------
1 | import Knex from 'knex';
2 | import knexStringcase from 'knex-stringcase';
3 |
4 | import getConnectionString from './services/getConnectionString';
5 |
6 | const knex = Knex(knexStringcase({
7 | client: 'postgres',
8 | connection: getConnectionString(),
9 | pool: { min: 0, max: 7 },
10 | // debug: true,
11 | // asyncStackTraces: true
12 | }));
13 |
14 | export default knex;
15 |
--------------------------------------------------------------------------------
/backend/db/migrations/1.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE course
4 | ADD COLUMN description TEXT,
5 | ADD COLUMN if_public BOOLEAN DEFAULT TRUE;
6 |
--------------------------------------------------------------------------------
/backend/db/migrations/10.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE problem ALTER COLUMN created_at
4 | SET DEFAULT timezone('UTC', now());
5 |
--------------------------------------------------------------------------------
/backend/db/migrations/11.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | CREATE TABLE coauthor (
4 | id SERIAL PRIMARY KEY,
5 | created_at TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()),
6 |
7 | user_id INTEGER REFERENCES "user" (id) NOT NULL,
8 | course_id INTEGER REFERENCES course (id) ON DELETE CASCADE NOT NULL,
9 |
10 | unique (user_id, course_id)
11 | );
12 |
--------------------------------------------------------------------------------
/backend/db/migrations/12.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | -- 1. Making coauthors ON DELETE CASCADE if user is deleted
4 | -- From https://stackoverflow.com/a/53214728/3192470
5 | ALTER TABLE coauthor
6 | DROP CONSTRAINT "coauthor_user_id_fkey",
7 | ADD CONSTRAINT "coauthor_user_id_fkey"
8 | FOREIGN KEY ("user_id")
9 | REFERENCES "public"."user"("id")
10 | ON DELETE CASCADE;
11 |
12 | -- 2. Changing all timezone('UTC', now()) to simple now()
13 | ALTER TABLE "user" ALTER COLUMN created_at SET DEFAULT now();
14 | ALTER TABLE "course" ALTER COLUMN created_at SET DEFAULT now();
15 | ALTER TABLE "problem" ALTER COLUMN created_at SET DEFAULT now();
16 | ALTER TABLE "course_user_is_learning" ALTER COLUMN started_learning_at SET DEFAULT now();
17 | ALTER TABLE "problem_user_is_learning" ALTER COLUMN last_reviewed_at SET DEFAULT now();
18 | ALTER TABLE "notification" ALTER COLUMN created_at SET DEFAULT now();
19 | ALTER TABLE "course_rating" ALTER COLUMN created_at SET DEFAULT now();
20 | ALTER TABLE "course_rating" ALTER COLUMN updated_at SET DEFAULT now();
21 | ALTER TABLE "coauthor" ALTER COLUMN created_at SET DEFAULT now();
22 |
--------------------------------------------------------------------------------
/backend/db/migrations/13.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE course
4 | ADD COLUMN duplicated_from_course_id INTEGER REFERENCES course (id) ON DELETE SET NULL DEFAULT NULL;
5 |
--------------------------------------------------------------------------------
/backend/db/migrations/14.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | -- Changing all timestamps to timestamptzs
4 | ALTER TABLE problem_user_is_learning ALTER next_due_date TYPE timestamptz USING next_due_date AT TIME ZONE 'UTC';
5 | ALTER TABLE problem_user_is_learning ALTER last_reviewed_at TYPE timestamptz USING last_reviewed_at AT TIME ZONE 'UTC';
6 | ALTER TABLE course_user_is_learning ALTER started_learning_at TYPE timestamptz USING started_learning_at AT TIME ZONE 'UTC';
7 | ALTER TABLE notification ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
8 | ALTER TABLE course_rating ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
9 | ALTER TABLE course_rating ALTER updated_at TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';
10 | ALTER TABLE coauthor ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
11 | ALTER TABLE "user" ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
12 | ALTER TABLE course ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
13 | ALTER TABLE problem ALTER created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
14 |
--------------------------------------------------------------------------------
/backend/db/migrations/15.sql:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE course
4 | ADD COLUMN embedding vector(1536);
5 |
6 | CREATE EXTENSION vector;
7 |
8 | CREATE TABLE items (id bigserial PRIMARY KEY, embedding vector(3));
--------------------------------------------------------------------------------
/backend/db/migrations/2.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE "user"
4 | ADD COLUMN email TEXT;
5 |
--------------------------------------------------------------------------------
/backend/db/migrations/3.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE "problem_user_is_learning"
4 | ADD COLUMN if_ignored BOOLEAN DEFAULT false;
5 |
--------------------------------------------------------------------------------
/backend/db/migrations/4.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE "user"
4 | ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now();
5 |
6 | ALTER TABLE course
7 | ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now();
8 |
9 | ALTER TABLE course_user_is_learning
10 | ADD COLUMN started_learning_at TIMESTAMP NOT NULL DEFAULT now();
11 |
12 | ALTER TABLE problem_user_is_learning
13 | ADD COLUMN last_reviewed_at TIMESTAMP NOT NULL DEFAULT now();
14 |
15 | -- table problem has created_at
16 |
17 | ALTER TABLE problem
18 | DROP COLUMN explanation;
19 |
--------------------------------------------------------------------------------
/backend/db/migrations/5.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | CREATE TABLE course_category_group (
4 | id SERIAL PRIMARY KEY,
5 | name VARCHAR NOT NULL
6 | );
7 |
8 | CREATE TABLE course_category (
9 | id SERIAL PRIMARY KEY,
10 | name VARCHAR NOT NULL,
11 | course_category_group_id INTEGER REFERENCES course_category_group (id) ON DELETE CASCADE NOT NULL
12 | );
13 |
14 | INSERT INTO course_category_group (name)
15 | VALUES ('Other');
16 |
17 | INSERT INTO course_category (name, course_category_group_id)
18 | VALUES ('Other', 1);
19 |
20 | ALTER TABLE course
21 | ADD COLUMN course_category_id INTEGER REFERENCES course_category (id) ON DELETE SET DEFAULT DEFAULT 1;
22 |
23 | -- Hard Sciences
24 | -- Mathematics
25 | -- Physics
26 | -- Astronomy
27 | -- Biology
28 | -- Programming Languages
29 | -- Computer Science
30 |
31 | -- Soft Sciences
32 | -- Politics
33 | -- Economics
34 | -- Psychology
35 | -- Law
36 | -- History
37 | -- Music
38 | -- Literature
39 |
40 | -- Languages
41 | -- English
42 | -- German
43 | -- Swedish
44 |
--------------------------------------------------------------------------------
/backend/db/migrations/6.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | CREATE TABLE notification (
4 | id SERIAL PRIMARY KEY,
5 | type VARCHAR NOT NULL,
6 | content JSON NOT NULL,
7 | if_read BOOLEAN NOT NULL,
8 | created_at TIMESTAMP NOT NULL DEFAULT now(),
9 |
10 | user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE NOT NULL
11 | );
12 |
--------------------------------------------------------------------------------
/backend/db/migrations/7.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | -- I made a mistake of using now() instead of timezone('UTC', now()) for defaults
4 | -- it's fine in production since heroku's postgres uses UTC as default timezone, but let's change it for localhost
5 |
6 | ALTER TABLE "user"
7 | ALTER COLUMN created_at
8 | SET DEFAULT timezone('UTC', now());
9 |
10 | ALTER TABLE "course"
11 | ALTER COLUMN created_at
12 | SET DEFAULT timezone('UTC', now());
13 |
14 | ALTER TABLE "course_user_is_learning"
15 | ALTER COLUMN started_learning_at
16 | SET DEFAULT timezone('UTC', now());
17 |
18 | ALTER TABLE "problem_user_is_learning"
19 | ALTER COLUMN last_reviewed_at
20 | SET DEFAULT timezone('UTC', now());
21 |
22 | ALTER TABLE "notification"
23 | ALTER COLUMN created_at
24 | SET DEFAULT timezone('UTC', now());
25 |
--------------------------------------------------------------------------------
/backend/db/migrations/8.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | -- DROP TABLE courseRatingByUser;
4 |
5 | CREATE TABLE course_rating (
6 | id SERIAL PRIMARY KEY,
7 |
8 | rating INTEGER CHECK (rating >= 1 AND rating <= 5),
9 |
10 | created_at TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()),
11 | updated_at TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()),
12 |
13 | course_id INTEGER REFERENCES course (id) ON DELETE CASCADE NOT NULL,
14 | user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE NOT NULL
15 | );
16 |
--------------------------------------------------------------------------------
/backend/db/migrations/9.sql.ran:
--------------------------------------------------------------------------------
1 | \c :database;
2 |
3 | ALTER TABLE problem
4 | ADD COLUMN position INTEGER DEFAULT 0;
5 |
--------------------------------------------------------------------------------
/backend/db/seed.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/backend/db/seed.js
--------------------------------------------------------------------------------
/backend/db/services/getConnectionString.js:
--------------------------------------------------------------------------------
1 | const getConnectionString = () => {
2 | switch (process.env.NODE_ENV) {
3 | // pgweb: postgres://postgres:§1§1§1@localhost:5432/memcode
4 | case 'development':
5 | return {
6 | host: 'localhost', // 'localhost' is the default;
7 | port: 5432, // 5432 is the default;
8 | database: 'memcode',
9 | user: process.env['DB_USER'],
10 | password: process.env['DB_PASSWORD']
11 | };
12 | case 'test':
13 | return {
14 | host: 'localhost', // 'localhost' is the default;
15 | port: 5432, // 5432 is the default;
16 | database: 'memcode_test',
17 | user: process.env['DB_USER'],
18 | password: process.env['DB_PASSWORD']
19 | };
20 | case 'production':
21 | // this variable is set automatically after we do heroku addons:create heroku-postgresql:hobby-dev
22 | return process.env.DATABASE_URL + '?ssl=true&sslmode=require';
23 | }
24 | };
25 |
26 | export default getConnectionString;
27 |
--------------------------------------------------------------------------------
/backend/db/services/pgOptions.js:
--------------------------------------------------------------------------------
1 | import * as pgPromise from 'pg-promise';
2 |
3 | /* eslint-disable */ // because it's taken from online source, may want to rewrite some time
4 | const _camelizeColumns = (data) => {
5 | const template = data[0];
6 | for (let prop in template) {
7 | const camel = pgPromise.utils.camelize(prop);
8 | if (!(camel in template)) {
9 | data.map((d) => {
10 | d[camel] = d[prop];
11 | delete d[prop];
12 | });
13 | }
14 | }
15 | };
16 |
17 | const pgOptions = {
18 | query: (e) => {
19 | const cyan = "\x1b[36m%s\x1b[0m";
20 | console.log(cyan, e.query); // log the query being executed
21 | },
22 | // https://coderwall.com/p/irklcq
23 | receive: (event) => {
24 | _camelizeColumns(event.data);
25 | },
26 | // disable warnings for tests,
27 | // because it was complaining a lot about duplicated connection
28 | noWarnings: process.env.NODE_ENV === 'test'
29 | };
30 |
31 | export default pgOptions;
32 |
--------------------------------------------------------------------------------
/backend/emails/sendWelcomeEmail.js:
--------------------------------------------------------------------------------
1 | import sendgrid from '~/emails/sendgrid';
2 |
3 | const sendWelcomeEmail = (email) => {
4 | const msg = {
5 | to: email,
6 | from: 'contact@memcode.com',
7 | subject: 'Sending with Twilio SendGrid is Fun',
8 | html: 'RIIIGHT??? and easy to do anywhere, even with Node.js',
9 | };
10 | return sendgrid.send(msg);
11 | };
12 |
13 | export default sendWelcomeEmail;
14 |
--------------------------------------------------------------------------------
/backend/emails/sendgrid.js:
--------------------------------------------------------------------------------
1 | import sendgrid from '@sendgrid/mail';
2 |
3 | sendgrid.setApiKey(process.env['SENDGRID_API_KEY']);
4 |
--------------------------------------------------------------------------------
/backend/index.js:
--------------------------------------------------------------------------------
1 | import 'source-map-support/register';
2 |
3 | import dayjs from 'dayjs';
4 | import relativeTime from 'dayjs/plugin/relativeTime';
5 | dayjs.extend(relativeTime);
6 |
7 | // load environment variables.
8 | import '../env.js';
9 |
10 | // inject router with middlewares and urls
11 | import '~/beforeMiddleware';
12 | import '~/api/urls';
13 | import '~/afterMiddleware';
14 |
15 | // process.env.PORT lets the port be set by Heroku
16 | const port = process.env.PORT || 3000;
17 |
18 | import router from './router';
19 | router.listen(port, (error) => {
20 | error ?
21 | console.log(`Server start error: ${error}`) :
22 | console.log(`Server is listening on port: ${port}`);
23 | });
24 |
--------------------------------------------------------------------------------
/backend/middlewares/allowCrossDomain.js:
--------------------------------------------------------------------------------
1 | const allowCrossDomain = (req, res, next) => {
2 | res.status(200);
3 | res.header("Access-Control-Allow-Origin", "*");
4 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
5 | next();
6 | };
7 |
8 | export default allowCrossDomain;
9 |
--------------------------------------------------------------------------------
/backend/middlewares/auth.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 |
3 | const auth = (callback) =>
4 | (request, response, next) => {
5 | const authorizationHeader = request.headers['authorization'];
6 | // (does work!)
7 | if (!authorizationHeader) next(new Error("No authorization header provided"));
8 |
9 | const token = authorizationHeader.split('Bearer ')[1];
10 | try {
11 | const user = jwt.verify(token, process.env['JWT_SECRET']);
12 | request.currentUser = user;
13 | callback(request, response, next);
14 | } catch (e) {
15 | // (does work too!)
16 | next(new Error("Couldn't authorize."));
17 | }
18 | };
19 |
20 | export default auth;
21 |
--------------------------------------------------------------------------------
/backend/middlewares/authenticate.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 |
3 | import handleErrors from './handleErrors';
4 |
5 | // make request.currentUser available
6 | // request.currentUser.oauthId,
7 | // request.currentUser.oauthProvider
8 | const authenticate = (request, response, next) => {
9 | if (request.headers['authorization']) {
10 | const token = request.headers['authorization'].split('Bearer ')[1];
11 | jwt.verify(token, process.env['JWT_SECRET'], (error, user) => {
12 | if (error) {
13 | handleErrors(error, request, response);
14 | } else {
15 | request.currentUser = user;
16 | next();
17 | }
18 | });
19 | } else {
20 | handleErrors(new Error("No authorization header provided"), request, response);
21 | }
22 | };
23 |
24 | export default authenticate;
25 |
--------------------------------------------------------------------------------
/backend/middlewares/bodyParser.js:
--------------------------------------------------------------------------------
1 | import bodyParser from 'body-parser';
2 |
3 | const middleware = [
4 | bodyParser.json({ limit: '50mb' }), // to support JSON-encoded bodies
5 | bodyParser.urlencoded({
6 | limit: '50mb', // otherwise will complain about image upload
7 | extended: true,
8 | parameterLimit: 50000
9 | })
10 | ];
11 |
12 | export default middleware;
13 |
--------------------------------------------------------------------------------
/backend/middlewares/guard.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 | import auth from '~/middlewares/auth';
3 |
4 | const guard = (getOurGuardData) => (body) => auth(guardInsides(getOurGuardData)(body));
5 |
6 | const guardInsides = (getOurGuardData) => (callback) => async (request, response, next) => {
7 | const [how, id] = getOurGuardData(request);
8 |
9 | switch (how) {
10 | case 'byPuilId': {
11 | const puil = (await knex('problemUserIsLearning').where({ id }))[0];
12 | const cuil = (await knex('courseUserIsLearning').where({ id: puil.courseUserIsLearningId }))[0];
13 | if (cuil.userId === request.currentUser.id) {
14 | callback(request, response, next);
15 | } else {
16 | next(new Error("Didn't pass guard."));
17 | }
18 | break;
19 | }
20 | case 'byCuilId': {
21 | const cuil = (await knex('courseUserIsLearning').where({ id }))[0];
22 | if (cuil.userId === request.currentUser.id) {
23 | callback(request, response, next);
24 | } else {
25 | next(new Error("Didn't pass guard."));
26 | }
27 | break;
28 | }
29 | }
30 | };
31 |
32 | export default guard;
33 |
--------------------------------------------------------------------------------
/backend/middlewares/injectResponseTypes.js:
--------------------------------------------------------------------------------
1 | const injectResponseTypes = (request, response, next) => {
2 | response.success = (obj) => {
3 | response.status(200).json(obj || {});
4 | };
5 | response.error = (string) => {
6 | response.status(500).json(string);
7 | };
8 | response.validation = (array) => {
9 | response.status(400).json(array);
10 | };
11 | next();
12 | };
13 |
14 | export default injectResponseTypes;
15 |
--------------------------------------------------------------------------------
/backend/middlewares/nocache.js:
--------------------------------------------------------------------------------
1 | // See (https://github.com/vuejs-templates/pwa/issues/70#issuecomment-369494375)
2 | const nocache = () => {
3 | return (req, res, next) => {
4 | res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
5 | res.header('Expires', '-1');
6 | res.header('Pragma', 'no-cache');
7 | next();
8 | };
9 | };
10 |
11 | export default nocache;
12 |
--------------------------------------------------------------------------------
/backend/middlewares/optionalAuthenticate.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 |
3 | const optionalAuthenticate = (request, response, next) => {
4 | if (request.headers['authorization']) {
5 | const token = request.headers['authorization'].split('Bearer ')[1];
6 | jwt.verify(token, process.env['JWT_SECRET'], (error, user) => {
7 | if (!error) request.currentUser = user;
8 | next();
9 | });
10 | } else {
11 | next();
12 | }
13 | };
14 |
15 | export default optionalAuthenticate;
16 |
--------------------------------------------------------------------------------
/backend/middlewares/stopPropagationForAssets.js:
--------------------------------------------------------------------------------
1 | // https://github.com/jaredhanson/passport/issues/14#issuecomment-21863553
2 | const stopPropagationForAssets = (req, res, next) => {
3 | if (req.url !== '/favicon.ico' && req.url !== '/styles.css') {
4 | return next();
5 | } else {
6 | res.status(200);
7 | res.header('Cache-Control', 'max-age=4294880896');
8 | res.end();
9 | }
10 | };
11 |
12 | export default stopPropagationForAssets;
13 |
--------------------------------------------------------------------------------
/backend/middlewares/webpackedFiles.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import express from 'express';
3 |
4 | const webpackedFiles =
5 | express.static(path.join(__dirname, '../../frontend/webpackedFiles'));
6 |
7 | export default webpackedFiles;
8 |
--------------------------------------------------------------------------------
/backend/models/CourseCategoryModel/index.js:
--------------------------------------------------------------------------------
1 | // CREATE TABLE course (
2 | // id SERIAL PRIMARY KEY,
3 | // title VARCHAR NOT NULL,
4 |
5 | // user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE NOT NULL
6 | // );
7 |
8 | import insert from './insert';
9 |
10 | export default { insert };
11 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/delete.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | // will delete all problems of this course and all course_user_is_learning
4 | const destroyCourseWithProblems = (courseId) =>
5 | db.none('DELETE FROM course WHERE id=${courseId}', { courseId });
6 |
7 | export default { destroyCourseWithProblems };
8 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/index.js:
--------------------------------------------------------------------------------
1 | import select from './select';
2 | import insert from './insert';
3 | import update from './update';
4 | import ddelete from './delete';
5 |
6 | export default {
7 | select,
8 | insert,
9 | update,
10 | delete: ddelete
11 | };
12 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/insert.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const create = (course) =>
4 | db.one(
5 | "INSERT INTO course (title, description, if_public, course_category_id, user_id, created_at) \
6 | VALUES (${title}, ${description}, ${ifPublic}, ${courseCategoryId}, ${userId}, now()) RETURNING *",
7 | {
8 | title: course.title,
9 | description: course.description,
10 | ifPublic: course.ifPublic,
11 | courseCategoryId: course.courseCategoryId || null,
12 | userId: course.userId
13 | }
14 | );
15 |
16 | export default { create };
17 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/select/index.test.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import db from '~/db/init';
4 | import { Factory } from '~/test/services/Factory';
5 |
6 | import Course from '../index';
7 |
8 | describe('course model', () => {
9 | describe('select', () => {
10 | describe('search', () => {
11 | beforeEach('truncating db', async () =>
12 | db.none('TRUNCATE TABLE problem, course, "user" RESTART IDENTITY CASCADE')
13 | );
14 |
15 | it('case-insensitive', async () => {
16 | await Factory.publicCourse({ title: 'Hello w' });
17 | const course_2 = await Factory.publicCourse({ title: 'Right' });
18 | const course_3 = await Factory.publicCourse({ title: 'Riper man' });
19 |
20 | const courses = await Course.select.search(1, "ri");
21 | expect(courses.map((c) => c.course.title)).to.have.members([course_2.title, course_3.title]);
22 | expect(courses.length).to.equal(2);
23 | });
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/select/services/wherePublic.js:
--------------------------------------------------------------------------------
1 | const wherePublic = `
2 | course.if_public = true
3 | AND
4 | (
5 | SELECT COUNT(problem.id) FROM problem WHERE problem.course_id = course.id
6 | ) >= 2
7 | `;
8 |
9 | export default wherePublic;
10 |
--------------------------------------------------------------------------------
/backend/models/CourseModel/update.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const update = {
4 | update: (id, values) =>
5 | db.one(
6 | `
7 | UPDATE course
8 | SET
9 | title = \${title},
10 | description = \${description},
11 | if_public = \${ifPublic},
12 | course_category_id = \${courseCategoryId}
13 | WHERE id = \${id}
14 | RETURNING *
15 | `,
16 | {
17 | ...values,
18 | id
19 | }
20 | )
21 | };
22 |
23 | export default update;
24 |
--------------------------------------------------------------------------------
/backend/models/CourseRatingModel/delete.js:
--------------------------------------------------------------------------------
1 | export default {};
2 |
--------------------------------------------------------------------------------
/backend/models/CourseRatingModel/index.js:
--------------------------------------------------------------------------------
1 | import select from './select';
2 | import insert from './insert';
3 | import update from './update';
4 | import ddelete from './delete';
5 |
6 | export default {
7 | select,
8 | insert,
9 | update,
10 | delete: ddelete
11 | };
12 |
--------------------------------------------------------------------------------
/backend/models/CourseRatingModel/insert.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const rate = ({ userId, courseId, rating }) =>
4 | db.one(
5 | `
6 | INSERT INTO course_rating
7 | (user_id, course_id, rating)
8 | VALUES
9 | (\${userId}, \${courseId}, \${rating})
10 | RETURNING *
11 | `,
12 | { userId, courseId, rating }
13 | );
14 |
15 | export default { rate };
16 |
--------------------------------------------------------------------------------
/backend/models/CourseRatingModel/select.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const oneOrNoneByUserAndCourse = ({ userId, courseId }) =>
4 | db.oneOrNone(
5 | 'SELECT * FROM course_rating WHERE user_id = ${userId} AND course_id = ${courseId}',
6 | { userId, courseId }
7 | );
8 |
9 | const anyByCourse = ({ courseId }) =>
10 | db.any(
11 | 'SELECT * FROM course_rating WHERE course_id = ${courseId}',
12 | { courseId }
13 | );
14 |
15 | export default { oneOrNoneByUserAndCourse, anyByCourse };
16 |
--------------------------------------------------------------------------------
/backend/models/CourseRatingModel/update.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const rate = ({ id, rating }) =>
4 | db.one(
5 | `
6 | UPDATE course_rating
7 | SET rating = \${rating}
8 | WHERE id = \${id}
9 | RETURNING *
10 | `,
11 | { id, rating }
12 | );
13 |
14 | export default { rate };
15 |
--------------------------------------------------------------------------------
/backend/models/CourseUserIsLearningModel/index.js:
--------------------------------------------------------------------------------
1 | // CREATE TABLE course_user_is_learning (
2 | // id SERIAL PRIMARY KEY,
3 |
4 | // active BOOLEAN,
5 |
6 | // course_id INTEGER REFERENCES course (id) ON DELETE CASCADE,
7 | // user_id INTEGER REFERENCES "user" (id) ON DELETE CASCADE,
8 | // unique (course_id, user_id)
9 | // );
10 |
11 | import select from './select';
12 | import update from './update';
13 |
14 | export default { select, update };
15 |
--------------------------------------------------------------------------------
/backend/models/CourseUserIsLearningModel/update.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const ifActive = (id, ifActiveOrNot) =>
4 | db.one(
5 | "UPDATE course_user_is_learning \
6 | SET active = ${active} \
7 | WHERE id = ${id} \
8 | RETURNING *",
9 | { active: ifActiveOrNot, id }
10 | );
11 |
12 | export default { ifActive };
13 |
--------------------------------------------------------------------------------
/backend/models/NotificationModel/index.js:
--------------------------------------------------------------------------------
1 | import insert from './insert';
2 |
3 | export default { insert };
4 |
--------------------------------------------------------------------------------
/backend/models/ProblemUserIsLearningModel/index.js:
--------------------------------------------------------------------------------
1 | import select from './select';
2 |
3 | export default { select };
4 |
--------------------------------------------------------------------------------
/backend/models/ProblemUserIsLearningModel/select.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const select = {
4 | allByCuilId: (cuilId) =>
5 | db.any(
6 | `
7 | SELECT *
8 | FROM problem_user_is_learning
9 | WHERE
10 | problem_user_is_learning.course_user_is_learning_id = \${cuilId}
11 | `,
12 | { cuilId }
13 | ),
14 | };
15 |
16 | export default select;
17 |
--------------------------------------------------------------------------------
/backend/models/UserModel/index.js:
--------------------------------------------------------------------------------
1 | import insert from './insert';
2 | import update from './update';
3 | import select from './select';
4 |
5 | export default { insert, select, update };
6 |
--------------------------------------------------------------------------------
/backend/models/UserModel/select.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const select = {
4 | // getUserByOauth('github', 7578559)
5 | // => user
6 | oneByOauth: (oauthProvider, oauthId) =>
7 | db.oneOrNone(
8 | 'SELECT * FROM "user" WHERE oauth_provider=${oauthProvider} and oauth_id=${oauthId}',
9 | {
10 | oauthProvider,
11 | oauthId: oauthId.toString()
12 | }
13 | ),
14 |
15 | one: (id) =>
16 | db.one(
17 | `SELECT * FROM "user" WHERE id = \${id}`,
18 | { id }
19 | )
20 | };
21 |
22 | export default select;
23 |
--------------------------------------------------------------------------------
/backend/models/UserModel/update.js:
--------------------------------------------------------------------------------
1 | import db from '~/db/init.js';
2 |
3 | const update = {
4 | update: (id, email) =>
5 | db.one(
6 | `
7 | UPDATE "user"
8 | SET email = \${email}
9 | WHERE id = \${id}
10 | RETURNING *
11 | `,
12 | { id, email }
13 | )
14 | };
15 |
16 | export default update;
17 |
--------------------------------------------------------------------------------
/backend/router.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | const router = express();
3 |
4 | export default router;
5 |
--------------------------------------------------------------------------------
/backend/services/canAccessCourse.js:
--------------------------------------------------------------------------------
1 | import knex from '~/db/knex';
2 |
3 | const canAccessCourse = async (courseId, currentUser) => {
4 | const course = (await knex('course').where({ id: courseId }))[0];
5 | if (course.ifPublic) return true;
6 | if (!currentUser) return false;
7 |
8 | const isAuthor = course.userId === currentUser.id;
9 | if (isAuthor) return true;
10 |
11 | const isCoauthor = (await knex('coauthor').where({ courseId, userId: currentUser.id }))[0];
12 | if (isCoauthor) return true;
13 | };
14 |
15 | export default canAccessCourse;
16 |
--------------------------------------------------------------------------------
/backend/services/catchAsync.js:
--------------------------------------------------------------------------------
1 | // catch Async Await function's error
2 | //
3 | // catchAsync can only accept async functions, because otherwise .catch is undefined.
4 | // (or they need to return a promise, but that's not the case with express endpoints)
5 | const catchAsync = (asyncFunction) =>
6 | (request, response, next) => {
7 | const promise = asyncFunction(request, response, next);
8 | promise
9 | .catch((error) => {
10 | next(error);
11 | });
12 | };
13 |
14 | export { catchAsync };
15 | export default catchAsync;
16 |
--------------------------------------------------------------------------------
/backend/services/createEmbedding.js:
--------------------------------------------------------------------------------
1 | const createEmbedding = async (input) => {
2 | const response = await fetch('https://api.openai.com/v1/embeddings', {
3 | method: 'POST',
4 | headers: {
5 | 'Authorization': `Bearer ${process.env.OPEANAI}`,
6 | 'Content-Type': 'application/json',
7 | },
8 | body: JSON.stringify(input),
9 | });
10 |
11 | const json = await response.json();
12 | return json.data[0].embedding;
13 | };
14 |
15 | export default createEmbedding;
16 |
--------------------------------------------------------------------------------
/backend/services/integerizeDbColumns.js:
--------------------------------------------------------------------------------
1 | // a{ courseUserIsLearning: b{ wow_yes: 1 }, amountOfProblems: 3 }
2 | const integerizeColumnsIn = (aOld, asTitlesArray) => {
3 | const aNew = {};
4 |
5 | Object.keys(aOld).forEach((key) => {
6 | asTitlesArray.includes(key) ?
7 | aNew[key] = parseInt(aOld[key], 10) :
8 | aNew[key] = aOld[key];
9 | });
10 |
11 | return aNew;
12 | };
13 |
14 | // https://github.com/vitaly-t/pg-promise/issues/118
15 | // COUNT(3) => to return js integer instead of string
16 | const integerizeDbColumns = (arrayOrHash, columnTitlesArray) => {
17 | if (Array.isArray(arrayOrHash)) {
18 | return arrayOrHash.map(hash =>
19 | integerizeColumnsIn(hash, columnTitlesArray)
20 | );
21 | } else {
22 | return integerizeColumnsIn(arrayOrHash, columnTitlesArray);
23 | }
24 | };
25 |
26 | export { integerizeDbColumns };
27 | export default integerizeDbColumns;
28 |
--------------------------------------------------------------------------------
/backend/services/requireKeys.js:
--------------------------------------------------------------------------------
1 | // const b = requireKeys(
2 | // ['userId', 'title'], // what to validate
3 | // (args) => {
4 | // console.log('creating a comment with title=' + args.title);
5 | // }
6 | // )
7 |
8 | // b({ userId: 'wow' })
9 | const requireKeys = (requiredKeys, functionBeingValidated) =>
10 | (realArgs) => {
11 | requiredKeys.forEach((key) => {
12 | if (realArgs[key] === undefined) {
13 | throw new Error(`${key} is required in function, but is undefined`);
14 | }
15 | });
16 |
17 | return functionBeingValidated(realArgs);
18 | };
19 |
20 | export { requireKeys };
21 |
--------------------------------------------------------------------------------
/backend/services/uploadFileToAwsS3.js:
--------------------------------------------------------------------------------
1 | import multer from 'multer';
2 | import multerS3 from 'multer-s3';
3 | import aws from 'aws-sdk';
4 |
5 | aws.config.update({
6 | accessKeyId: process.env['AWS_ACCESS_KEY_ID'],
7 | secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'],
8 | region: process.env['AWS_REGION']
9 | });
10 |
11 | const awsS3 = new aws.S3();
12 |
13 | const uploadFileToAwsS3 = multer({
14 | storage: multerS3({
15 | s3: awsS3,
16 | bucket: process.env['AWS_BUCKET_NAME'],
17 | acl: 'public-read',
18 | metadata: (req, file, cb) => {
19 | cb(null, { fieldName: file.fieldname });
20 | },
21 | // we are making a timestamp of a current time and saving this file under this name.
22 | key: (req, file, cb) => {
23 | cb(null, Date.now().toString());
24 | }
25 | })
26 | });
27 |
28 | export default uploadFileToAwsS3;
29 |
--------------------------------------------------------------------------------
/backend/test/Readme.md:
--------------------------------------------------------------------------------
1 | This is the folder for global backend tests, and for services used in all tests.
2 | Other component-specific tests are placed next to components themselves.
3 |
--------------------------------------------------------------------------------
/backend/webpack/development.config.js:
--------------------------------------------------------------------------------
1 | const glob = require('glob');
2 | const sharedConfig = require('./sharedConfig');
3 |
4 | const getTestEntries = () => {
5 | const testFiles = glob.sync('./**/*.test.js', { ignore: './webpacked/**' });
6 | const testEntries = {};
7 | testFiles.forEach((testFile) => {
8 | testEntries['test/' + testFile.slice(2, -3)] = testFile;
9 | });
10 | };
11 |
12 | const entries = {
13 | ...getTestEntries(),
14 | ...sharedConfig._partialEntry
15 | };
16 |
17 | module.exports = {
18 | externals: sharedConfig.externals,
19 |
20 | entry: entries,
21 | output: sharedConfig.output,
22 |
23 | target: sharedConfig.target,
24 | node: sharedConfig.node,
25 |
26 | module: sharedConfig.module,
27 |
28 | resolve: sharedConfig.resolve,
29 |
30 | devtool: 'source-map',
31 | mode: 'development'
32 | };
33 |
--------------------------------------------------------------------------------
/backend/webpack/production.config.js:
--------------------------------------------------------------------------------
1 | const sharedConfig = require('./sharedConfig');
2 |
3 | module.exports = {
4 | externals: sharedConfig.externals,
5 |
6 | entry: sharedConfig._partialEntry,
7 | output: sharedConfig.output,
8 |
9 | target: sharedConfig.target,
10 | node: sharedConfig.node,
11 |
12 | module: sharedConfig.module,
13 |
14 | resolve: sharedConfig.resolve,
15 |
16 | mode: 'production'
17 | };
18 |
--------------------------------------------------------------------------------
/frontend/api/FileApi.js:
--------------------------------------------------------------------------------
1 | import fetchWrapper from './services/fetchWrapper';
2 |
3 | const upload = (dispatch, file) => {
4 | const formData = new FormData();
5 | // 'file' string can be anything, it just has to correspond to uploadFileToAwsS3.single('file')
6 | formData.append('file', file);
7 |
8 | return fetchWrapper(
9 | dispatch,
10 | fetch('/api/files/upload', {
11 | method: 'POST',
12 | body: formData
13 | })
14 | );
15 | };
16 |
17 | export default {
18 | upload
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/api/services/fetchWrapper.js:
--------------------------------------------------------------------------------
1 | import speCreator from '~/services/speCreator.js';
2 | import handleErrors from './handleErrors';
3 |
4 | const fetchWrapper = (dispatch, fetchPromise) => {
5 | if (dispatch) dispatch(speCreator.request());
6 | return fetchPromise
7 | .then(handleErrors)
8 | .then((response) => {
9 | if (dispatch) dispatch(speCreator.success(response));
10 | return response;
11 | })
12 | .catch((error) => {
13 | // ___why should we keep this even though we see network responses anyway?
14 | // because nonnetwork errors can get swallowed here just as well.
15 | console.log(error);
16 | if (dispatch) dispatch(speCreator.failure(error));
17 | return Promise.reject(error);
18 | });
19 | };
20 |
21 | export default fetchWrapper;
22 |
--------------------------------------------------------------------------------
/frontend/api/services/fileFetch.js:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/api/services/handleErrors.js:
--------------------------------------------------------------------------------
1 | const handleErrors = (response) => {
2 | if (response.status === 200) {
3 | return response.json()
4 | .catch((error) => Promise.reject(error.toString()));
5 | } else {
6 | return response.json()
7 | .then((error) => Promise.reject(error))
8 | .catch((error) => Promise.reject(error.toString()));
9 | }
10 | };
11 |
12 | export { handleErrors };
13 | export default handleErrors;
14 |
--------------------------------------------------------------------------------
/frontend/api/services/hashToQueryString.js:
--------------------------------------------------------------------------------
1 | // @param hash - { SearchQuery: '', pageSize: '', passengerId: 15 }
2 | // => SearchQuery=Hello&pageSize=3
3 | const hashToQueryString = (hash) =>
4 | Object.keys(hash).map((key) =>
5 | key + '=' + encodeURIComponent(hash[key])
6 | ).join('&');
7 |
8 | export default hashToQueryString;
9 |
--------------------------------------------------------------------------------
/frontend/appComponents/CourseCardSimple/index.css:
--------------------------------------------------------------------------------
1 | @import 'css/variables';
2 |
3 | :local(.a){
4 | width: 199px;
5 | height: 213px;
6 | background: $color-main-3;
7 | overflow: hidden;
8 |
9 | h2.title{
10 | margin-top: 27px;
11 | text-transform: capitalize;
12 | }
13 |
14 | div.description{
15 | display: none;
16 | // margin-top: 10px;
17 | // line-height: 15px;
18 | // word-break: break-word;
19 | // font-size: 12px;
20 | // color: $not-so-faint-blue;
21 | }
22 |
23 | position: relative;
24 | section.total-amount-of-flashcards{
25 | position: absolute;
26 | bottom: 0; left: 0;
27 | width: 100%;
28 | text-align: center;
29 | padding-bottom: 3px;
30 | padding-top: 3px;
31 | background: rgb(136, 125, 220);
32 | color: white;
33 | font-size: 11px;
34 | }
35 |
36 | a.play-button{
37 | display: block;
38 | position: absolute;
39 | bottom: 31px;
40 | left: 31px;
41 | z-index: 10000;
42 | // opacity: 0;
43 | }
44 | &:hover{
45 | a.play-button{
46 | opacity: 1;
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/appComponents/CourseCategoryFormLine/index.js:
--------------------------------------------------------------------------------
1 | import { FormLineLayout } from '~/components/_standardForm';
2 | import CourseCategorySelect from './components/CourseCategorySelect';
3 |
4 | class CourseCategoryFormLine extends React.Component {
5 | static propTypes = {
6 | label: PropTypes.string.isRequired,
7 | name: PropTypes.string.isRequired,
8 | updateFormState: PropTypes.func.isRequired,
9 | formState: PropTypes.object.isRequired,
10 | formValidation: PropTypes.object.isRequired
11 | }
12 |
13 | updateFormState = (value) =>
14 | this.props.updateFormState({
15 | ...this.props.formState,
16 | [this.props.name]: value
17 | })
18 |
19 | render = () =>
20 | {this.props.title}
19 |
20 | {this.props.children}
21 |
17 | We implement a variation of an SM2 algorithm for spaced repetition.
18 | It's based on repeating something you want to learn in ever increasing inervals.
19 | We learn the best when we just begin to forget, and our purpose is to make you review your flashcards at the best timing.
20 |
21 | First review will happen in about 4 hours, next one in 24 hours, next in 3 days and so on.
22 |
CTRL+S | 26 |will save the new flashcard. Existing flashcards will also get saved automatically when you remove the focus away from them. | 27 |
CTRL+B | 30 |bold text | 31 |
CTRL+K | 34 |code block | 35 |
I love you more
' 2 | // => matchedStrings ["love", "more"] 3 | const amountOfAnswerInputsInProblem = (problem) => { 4 | const matchedStrings = problem.content.content.match(/(.*?)<\/mark>/g); 5 | if (matchedStrings === null) { 6 | return 0; 7 | } else { 8 | return matchedStrings.length; 9 | } 10 | }; 11 | 12 | export default amountOfAnswerInputsInProblem; 13 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/duck/services/calculateScore.js: -------------------------------------------------------------------------------- 1 | // to problem model? 2 | const calculateScore = (given, wanted) => { 3 | if (given === wanted) { 4 | return 5; 5 | } else { // given < wanted 6 | return (given / wanted) * 5; // 0..5 7 | } 8 | }; 9 | 10 | 11 | export default calculateScore; 12 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/duck/services/freshStatusOfSolving.js: -------------------------------------------------------------------------------- 1 | import amountOfAnswerInputsInProblem from './amountOfAnswerInputsInProblem'; 2 | 3 | const freshStatusOfSolving = (problem, index) => { 4 | // index, // reference to the currentProblem 5 | // status: 'solving', // or 'seeingAnswer' == 'givingRating (default 5)' == 'nextProblem' (in the same time) 6 | if (!problem) return { index: -1 }; // no more problems 7 | 8 | switch (problem.type) { 9 | case 'inlinedAnswers': 10 | return { 11 | index, 12 | status: amountOfAnswerInputsInProblem(problem) === 0 ? 'seeingAnswer' : 'solving', 13 | typeSpecific: { amountOfRightAnswersGiven: 0, selfScore: 5 } 14 | }; 15 | case 'separateAnswer': 16 | return { 17 | index, 18 | status: 'solving', 19 | typeSpecific: { selfScore: 5 } 20 | }; 21 | default: 22 | throw Error(`Problem.type is ${problem.type}, we don't know it`); 23 | } 24 | }; 25 | 26 | export default freshStatusOfSolving; 27 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/duck/services/playLongSound.js: -------------------------------------------------------------------------------- 1 | import playSound from '~/services/playSound'; 2 | import longSound from '../../short_shimmer.mp3'; 3 | 4 | const playLongSound = (score, currentProblem) => { 5 | if (score === undefined || (currentProblem.type === 'separateAnswer' && score === 5)) { 6 | // console.error('playing long sound'); 7 | playSound(longSound, { volume: 0.1 }); 8 | } 9 | }; 10 | 11 | export default playLongSound; 12 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/duck/services/playShortSound.js: -------------------------------------------------------------------------------- 1 | import playSound from '~/services/playSound'; 2 | import shortMusic from '../../real_short.mp3'; 3 | 4 | const playShortSound = () => { 5 | // console.error('playing short sound'); 6 | playSound(shortMusic, { volume: 0.12 }); 7 | }; 8 | 9 | export default playShortSound; 10 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/index.css: -------------------------------------------------------------------------------- 1 | :local(.main){ 2 | .ProblemBeingSolved{ 3 | padding-bottom: 90px; 4 | } 5 | &.-hide-draft{ 6 | .ProblemBeingSolved .draft-answer{ 7 | display: none !important; 8 | } 9 | } 10 | &.-is-embed{ 11 | header, 12 | section.course-actions{ 13 | display: none !important; 14 | } 15 | .review-container .problem{ margin-top: 30px; } 16 | .self-score{ margin-top: 35px; } 17 | .next-button{ margin-top: 30px; } 18 | } 19 | footer{ 20 | display: none; 21 | } 22 | .loading.loading-flashcards{ 23 | margin-top: 100px; 24 | } 25 | @media(max-width: 900px){ 26 | > header section.search, 27 | > header nav{ 28 | display: none; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/real_shimmer.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/frontend/pages/courses_id_review/real_shimmer.mp3 -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/real_short.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/frontend/pages/courses_id_review/real_short.mp3 -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/shimmer.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/frontend/pages/courses_id_review/shimmer.mp3 -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/short.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/frontend/pages/courses_id_review/short.mp3 -------------------------------------------------------------------------------- /frontend/pages/courses_id_review/short_shimmer.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lakesare/memcode/1dd4d3b20d118c656409b5d2114a2d317357e62a/frontend/pages/courses_id_review/short_shimmer.mp3 -------------------------------------------------------------------------------- /frontend/pages/courses_new/index.css: -------------------------------------------------------------------------------- 1 | :local(main.main){ 2 | } 3 | -------------------------------------------------------------------------------- /frontend/pages/home/index.css: -------------------------------------------------------------------------------- 1 | @import 'css/variables'; 2 | 3 | :local(main.main){ 4 | .standard-course-card{ 5 | .title{ 6 | margin-top: 23px; 7 | } 8 | .description{ 9 | display: none; 10 | } 11 | } 12 | 13 | .home-section{ 14 | display: flex; 15 | align-items: flex-start; 16 | padding: 20px; 17 | background: rgb(20, 23, 46); 18 | border-radius: 10px; 19 | margin-bottom: 20px; 20 | h1{ 21 | width: 298px; 22 | flex-shrink: 0; 23 | } 24 | 25 | .standard-course-card{ 26 | margin-bottom: 0; 27 | } 28 | 29 | .layout-div{ 30 | display: none; 31 | } 32 | 33 | // .standard-course-card .title{} 34 | } 35 | 36 | h1{ 37 | margin-bottom: 20px; 38 | margin-bottom: 40px; 39 | } 40 | 41 | 42 | } 43 | -------------------------------------------------------------------------------- /frontend/pages/offline_courses/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Main from '~/appComponents/Main'; 3 | 4 | // import css from './index.css'; 5 | 6 | class Page extends React.Component { 7 | render = () => 8 |12 | You may look at the existing courses, or create your own course. 13 |
14 |