The Strapi Users and Permissions plugin allows you to add an authentication layer to your app using JSON Web Tokens. If a user is successfully authenticated, they are provided with a JWT token. When making authenticated requests, this token is passed in an Authorization header that Strapi checks to determine whether the user is allowed to access the resource they requested. The Users and Permissions plugin also bundles an ACL strategy. With it, you can authorize different groups of users to access resources and set permissions for them.
This tutorial is a continuation to “Build an Angular quiz app with Strapi”. That tutorial explained how to create a Strapi app with two content types: Quiz and Question. The app included an extra route to score a user’s answers to a quiz. The initial Strapi app was built using Strapi v3. The front-end of the app was built with Angular 11. It had three pages: one to list all the available quizzes, one where a user takes the quiz, and the last one to show scores.
In this tutorial, you will continue building the Angular app. However, you will have to create a new Strapi app for the backend because Strapi has moved to a new major version, v4. Since Strapi v4 and v3 are not backwards compatible and you cannot currently upgrade from v3 to v4, you will have to build the Strapi backend app from scratch. After adding the Question and Quiz content types, you will add Score and Answer types to track the user’s quiz attempts. Next, you will modify multiple Score controllers to score the quiz answers. Then you will change some API plugin permissions to allow actions like fetching and creating content for both public and authenticated roles. Lastly, you will add sample quizzes and questions that will be displayed on the Angular app, which you will build out in part 2.
Goals
By the end of this tutorial, you should have:
- Generated a Strapi app.
- Created four (4) content types: Quiz, Question, Answer, and Score
- Extended the
create,find, andfindOneScore controllers. - Changed the API plugin permissions for Authenticated and Public roles for the four (4) content types.
- Added question and quiz sample data to display on the frontend.
Prerequisites
To follow along with this tutorial, you will need Node.js installed. Strapi requires either Node.js v12 or v14; however, v14 is recommended. If you use a different version of Node.js from those two, you will run into problems generating the Strapi app. You can find pre-built installers for Node.js on the Node.js website downloads page..
The Strapi App
As mentioned earlier, Strapi released a new major version of their headless CMS, v4. It’s currently impossible to upgrade the initial Strapi app built with v3 to v4. So you’ll have to create the backend from scratch. You will generate a new v4 Strapi app, add four content types, and modify routes to score quiz answers.
Step 1 - Generate the Strapi App
Call the backend strapi-quiz-app. To generate it, run this command on your terminal:
npx create-strapi-app@latest strapi-quiz-app --quickstartRunning this command will generate the Strapi app, install its dependencies, and start it. The --quickstart flag initiates app creation with the quickstart system where the database used is SQLite. The app will be made available at http://localhost:1337 on your browser. Once the app has started, you will be redirected to http://localhost:1337/admin/auth/register-admin, where you will be prompted to register an admin account. Below is a screenshot of what this should look like.
Once you’ve signed up, you’ll be redirected to the Strapi Dashboard. Here’s where you will create content and manage plugin settings. The Strapi dashboard looks like the screenshot below:
Step 2 - Create Content Types
The Strapi app will have four content types:
- Quiz: to collect quizzes
- Question: to collect quiz questions
- Answer: to collect answers users input when taking the quizzes
- Score: to collect quiz attempts
You’ll create the content types using the Strapi CLI. The Strapi app may crash as you do this. If it is still running from the last step, stop it on the terminal. You can restart it once you finish creating the content types.
- The Question Content Type
The Question content type will collect questions, the available options, and the correct answers. It will have five attributes:
text: the body of the question. It is atexttype.a: the A choice. It is atexttype.b: the B choice. It is atexttype.c: the C choice. It is atexttype.d: the D choice. It is atexttype.answer: the answer (a,b,c, ord). It is anenumerationtype.
To create this content type, run the command below on your terminal at the app’s root directory:
npm run strapi generateThe above command launches the interactive Strapi API generator. You will be guided through a series of prompts. Answer these prompts using the inputs shown in the screenshot below. It will guide you on how to create the Question content type.
After creating the Question content type using the API generator, its schema, controller, routes, and service files will be generated for it in the /api/questions folder. This new content type will also appear on the Strapi dashboard.
The last thing to do in this step is make all the attributes for the Question content type required. You can do this by replacing the content of src/api/question/content-types/question/schema.json with this below:
1 {
2 "kind": "collectionType",
3 "collectionName": "questions",
4 "info": {
5 "singularName": "question",
6 "pluralName": "questions",
7 "displayName": "Question",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false,
12 "comment": ""
13 },
14 "attributes": {
15 "text": {"type": "text","required": true},
16 "a": {"type": "text","required": true},
17 "b": {"type": "text","required": true},
18 "c": {"type": "text","required": true},
19 "d": {"type": "text","required": true},
20 "answer": {"type": "enumeration","required": true,"enum": ["a","b","c","d"]}
21 }
22 }The highlighted portions are the changes that were made to the schema. For each of the attributes, add the "required": true property as shown in the code snippet above.
In this step, you created the Question content type. In the next one, you will create the Quiz content type.
- The Quiz content type
The Quiz content type collects quizzes, their titles, descriptions, and questions. It will have three attributes:
title: the name of the quiz. It is astringtype.description: what the quiz is about. It is atexttype.questions: all the questions in the quiz. It is arelationtype.
To generate the Quiz content type, run the command below on your terminal.
npm run strapi generateUse the screenshot below to guide you on what to input as responses to the API generator prompts.
Its schema, controllers, services, and routes are added to the /api/quiz folder. The API generator doesn’t allow you to add relation attributes with it. The questions attribute will have to be added to the Quiz schema file. You will also make the title and the description attributes required. Copy the code below to src/api/quiz/content-types/quiz/schema.json.
1 {
2 "kind": "collectionType",
3 "collectionName": "quizzes",
4 "info": {
5 "singularName": "quiz",
6 "pluralName": "quizzes",
7 "displayName": "Quiz",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false,
12 "comment": ""
13 },
14 "attributes": {
15 "title": {"type": "string","required": true},
16 "description": {"type": "text","required": true},
17 "questions": {"type": "relation","relation": "oneToMany","target": "api::question.question"}
18 }
19 }The highlighted portions are the changes that were made to the schema. The questions attribute is a oneToMany relation, where a quiz has many questions. In this step, you created the Quiz content type. In the next one, you will create the Answer content type.
- The Answer content type
The Answer content type collects the users’ answers. It will have three attributes:
question: the question the user answered. It is arelationtype.value: the actual value of the answer. It is astringtype.score: the quiz attempt that the answer is a part of. It is arelationtype. Thescorecontent type does not exist currently, so you will omit this field and add it later.
To generate the Answer content type, run the command below on your terminal.
npm run strapi generateUse the screenshot below to guide you on what to input as responses give to the API generation prompts.
Its schema, controllers, services, and routes are added to the /api/answer folder. As mentioned earlier, the API generator doesn’t allow you to add relation attributes. The question attribute should be added to the Answer schema file. Also, make the value attribute required. Copy the code below to src/api/answer/content-types/answer/schema.json.
1 {
2 "kind": "collectionType",
3 "collectionName": "answers",
4 "info": {
5 "singularName": "answer",
6 "pluralName": "answers",
7 "displayName": "Answer",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false,
12 "comment": ""
13 },
14 "attributes": {
15 "value": {"string": "text","required": true},
16 "question": {"type": "relation","relation": "oneToOne","target": "api::question.question"}
17 }
18 }The highlighted portions are the changes that were made to the schema. The question attribute is a oneToOne relation, where an answer only has one question. In this step, we created the Answer content type.
In this step, you created the Answer content type. In the next one, you will create the Score content type.
- The Score Content Type
The Score content type collects all the answers a user has given for a quiz. It has three attributes:
quiz: the quiz that the score is created from. It is arelationtype.user: the owner of the score. It is arelationtype.answers: all the answers the user gave for the quiz. It is arelation type.
To generate the Score content type, run the command below on your terminal.
npm run strapi generateUse the screenshot below to guide you on what to input as responses to the API generation prompts.
Its schema, controllers, services, and routes are added to the /api/score folder. Since all the attributes are relations, you won’t be able to add any of them using the API generator as it doesn’t allow it. The quiz, user, and answers attributes should be added to the Score schema file manually. Copy the code below to src/api/answer/content-types/score/schema.json.
1 {
2 "kind": "collectionType",
3 "collectionName": "scores",
4 "info": {
5 "singularName": "score",
6 "pluralName": "scores",
7 "displayName": "Score",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false,
12 "comment": ""
13 },
14 "attributes": {
15 "quiz": {"type": "relation","relation": "oneToOne","target": "api::quiz.quiz"},
16 "user": {"type": "relation","relation": "oneToOne","target": "plugin::users-permissions.user"},
17 "answers": {"type": "relation","relation": "oneToMany","target": "api::answer.answer","mappedBy": "score"}
18 }
19 }The highlighted portions are the changes that were made to the schema. The Quiz attribute is a oneToOne relation, where a score has one quiz. The User attribute is also a oneToOne relation, where a score has one user. The Answer attribute is a oneToMany relation, where a score belongs to many answers.
In this step, you created the Score content type. In the next one, you will update the Answer content type to have a score attribute.
- Update the Answer Content Type
Now that the Score content type has been created, you can add the
scoreattribute to the Answer content type. Do this by copying the code below to src/api/answer/content-types/answer/schema.json.
1 {
2 "kind": "collectionType",
3 "collectionName": "answers",
4 "info": {
5 "singularName": "answer",
6 "pluralName": "answers",
7 "displayName": "Answer",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": false,
12 "comment": ""
13 },
14 "attributes": {
15 "value": {"type": "text","required": true},
16 "question": {"type": "relation","relation": "oneToOne","target": "api::question.question"},
17 "score": {"type": "relation","relation": "manyToOne","target": "api::score.score","inversedBy": "answers"}
18 }
19 }The highlighted portions are the changes that were made to the schema. The score attribute is a manyToOne relation, where a score has many answers.
In this step, you created four content types: Question, Quiz, Answer, and Score. In the Next step, you will, let’s extend the Score controllers to make them calculate scores based on the answers of the score.
Step 3 - Extending the Score Controllers
In this step, you will modify the create, find, and findOne Score controllers. Typically, the Score controllers perform the basic CRUD operations. However, the create controller, in addition to creating a score, also needs to create user’s answers when a score is created. The find and findOne controllers need to fetch the user’s answers, calculate the score total based on the answers, and send the score total as a response.
To begin, change the content of src/api/score/controllers/score.js to:
1 'use strict';
2 /**
3 * score controller
4 */
5 const { createCoreController } = require('@strapi/strapi').factories;
6
7 module.exports = createCoreController('api::score.score', ({ strapi }) => ({
8 // place modified controllers here
9 }));Here, you are adding a second argument to the createCoreController factory that returns the modified controllers. You’ll place the extended create, find, and findOne controllers here. You will also add a helper function that calculates the score total above the exports.
- Extend the create Score Controller
In the create Score controller, the user’s answers are added to the database and a score is created to mark a quiz attempt. The score total is then calculated based on the answers given and returned as a response. Add the code below in the src/api/score/controllers/score.js file, where the // place modified controllers here comment is.
1 async create(ctx) {
2 let { answers } = ctx.request.body;
3 const quizId = ctx.request.body.quiz.id;
4
5 try {
6 let quiz = await strapi.service('api::quiz.quiz').findOne(quizId, { populate: ['questions'] });
7 '
8 let score = await strapi.service('api::score.score').create({
9 data: {
10 quiz: quizId,
11 user: ctx.state.user.id
12 }
13 });
14
15 let question, userAnswer;
16 let scoreTotal = 0;
17
18 score.answers = [];
19
20 for (let i = 0; i < answers.length; i++) {
21 userAnswer = answers[i];
22
23 question = quiz.questions.find((qst) => qst.id === userAnswer.question.id);
24 if (question) {
25 await strapi.service('api::answer.answer').create({
26 data: {
27 question: question.id,
28 score: score.id,
29 value: userAnswer.value
30 }
31 });
32
33 if (question.answer === userAnswer.value) {
34 userAnswer.correct = true;
35 scoreTotal += 1;
36 } else {
37 userAnswer.correct = false;
38 }
39 userAnswer.correctValue = question.answer;
40 }
41 score.answers.push(userAnswer);
42 }
43 const questionCount = quiz.questions.length;
44 delete quiz.questions;
45
46 return { questionCount, scoreTotal, quiz, score };
47 } catch {
48 ctx.response.status = 500;
49 return { error: { message: 'There was a problem scoring your answers'}};
50 }
51 },To begin, we will retrieve the answers and quizId from the request body, ctx.request.body. Using the quizId, we’ll fetch the attempted quiz with its associated questions populated. Then, a new score is created with the current user and quiz. For each of the answers a user has provided, a corresponding question is identified. Each answer, userAnswer, is then saved to the database with its value and associated score and question. Next, a check is performed to verify whether the value for the userAnswer matches the correct answer for the question. If it does, a correct property is added to it, indicating that it is correct and the scoreTotal is incremented. A correctValue property is also added, so that if the user got it wrong, they can see the correct answer. Lastly, all the correct answers are added to the score. Then the score , questionCount, quiz, and scoreTotal are returned. In an error occurs, a 500 status and message are returned.
In this section, you modified the create Score controller. In the next one, you will add a helper function to score user answers when saved scores are fetched.
- Add a Helper Function for Scoring
In this section, you will add a function called calculateScore, which calculates scores for the find and findOne Score controllers. When a score is retrieved, the score total is calculated from the answers by the calculateScore function before it is returned. In the src/api/score/controllers/score.js file, add this code before the exports.
1 function calculateScore(score) {
2 let question;
3 let scoreTotal = 0;
4
5 score.answers.map((userAnswer) => {
6 question = userAnswer.question;
7
8 if (question.answer == userAnswer.value) {
9 userAnswer.correct = true;
10 scoreTotal += 1;
11 } else {
12 userAnswer.correct = false;
13 }
14
15 userAnswer.correctValue = question.answer;
16 delete userAnswer.question;
17
18 return userAnswer;
19 });
20
21 const questionCount = score.quiz.questions.length;
22 delete score.quiz.questions;
23
24 let resp = {
25 questionCount,
26 scoreTotal,
27 quiz: score.quiz
28 };
29
30 delete score.quiz;
31
32 return { ...resp, score };
33 }This function takes a score parameter. For each of the answers, similar to what happens in the create Score controller, a check is performed against its value to see if it is correct. If it is, it is marked as correct and the scoreTotal is incremented. The correct answer to the question is also added to the scored answer. Lastly, the quiz, questionCount, scoreTotal, and score are returned as one object. In this section, you added a calculateScore function. In the next one, you will use this function in the find and findOne Score controllers.
- Extend the findOne Score Controller
We need to modify the findOne Score controller, so that the score total is generated and added to the score. In the src/api/score/controllers/score.js file, add the following code below the create controller.
1 async findOne(ctx) {
2 const { id } = ctx.params;
3
4 try {
5 let score = await strapi.service('api::score.score').findOne(id, {
6 filters: { user: ctx.state.user.id },
7 populate: {
8 answers: { populate: ['question'] },
9 quiz: { populate: ['questions']}
10 }
11 });
12
13 return calculateScore(score);
14 }
15 catch {
16 ctx.response.status = 500;
17 return { error: { message: 'There was a problem fetching your score.' }};
18 }
19 },You’ll begin by fetching the score id from the request route parameters. Then, using this id, a score is fetched with the user’s answers and the quiz associated with it is populated. The score total for the score is then calculated and returned. If there is an error, a 500 status and message are returned instead. In this section, you modified the findOne Score controller to calculate score totals. In the next section, you will modify the find Score controller to do the same.
- Extend the find Score Controller
We will modify the find Score controller to calculate the total for each of the fetched scores. In the src/api/score/controllers/score.js file, add the following code below the findOne controller.
1 async find(ctx) {
2 try {
3 const { query } = ctx;
4
5 let scores = await strapi.service('api::score.score').find({
6 filters: { user: ctx.state.user.id },
7 populate: {
8 answers: { populate: ['question'] },
9 quiz: { populate: ['questions'] }
10 },
11 ...query
12 });
13
14 return scores.results.map(calculateScore);
15 } catch {
16 ctx.response.status = 500;
17 return { error: { message: 'There was a problem fetching your scores.' }};
18 }
19 }Start by getting the query from the context which returns a parsed version of the request query-string. Then, you’ll fetch all the scores that belong to the user with the answers and quiz populated. The scores are filtered using any values provided in the query. You’ll then calculate the score total for each of the fetched scores and return them as a response. If any problem occurs, an error message and a 500 status are returned instead.
In this step, you extended the create, find, and findOne Score controllers to calculate scores. In the next step, you will change the roles settings for the Users and Permissions plugin to make various API routes accessible.
Step 4 - Change API Plugin Permissions for Authenticated and Public Roles
Change the allowed actions for various API plugins for the authenticated and public roles in the Users and Permissions ***plugin settings. Modify the API plugin permissions for the authenticated user role so that they can access some Score and Answer routes. Then, change the API plugin permissions for an unauthenticated/public user so that they can access some Question and Quiz* routes.
If you haven’t started your Strapi app since step 2, you can run it from the terminal within its root directory using this command:
npm run developOn the Public Role settings page for the Users and Permissions plugin, locate the Quiz and Questions API plugin settings under the Permissions section. Select the find and findOne checkboxes to make these three allowed actions for both the Quiz and Question API plugins.
On the Authenticated Role settings page for the Users and Permissions plugin, locate the Answer and Score API plugin settings under the Permissions section. Select the create, find, and findOne checkboxes to make these three allowed actions for both the Answer and Score API plugins. The Answer and Score actions need to be authenticated because you’ll need the user’s information to create and fetch answers and scores.
In this step, you modified the allowed actions for the Question, Quiz, Answer, and Score API plugins for both authenticated and public roles. In the following step, you will add sample Question and Quiz content.
Step 5 - Adding Sample Question and Quiz Content
In this step, you will add sample question and quiz content. These will be displayed on the frontend. If you have alternative ideas for questions and quizzes, you can use those instead. On the Question Content Manager “Create an entry” page, add these four questions with the data shown in the screenshots. Once you have added the content, click the bright blue Save button located at the top right corner of the page.
On the Quiz Content Manager “Create an entry” page, add these three quizzes with the data shown in the screenshots. Make sure that all the four available questions are added to each of the quizzes in the“Questions” Relation section. Once you’re finished inputting the content, click the bright blue Save button located at the top right corner of the page to create them.
You may also need to add a user who will take the tests. You can do this now on the dashboard or later on the frontend on the signup page. If you opt to do this now, add a user on the User Content Manager “Create an entry” page. In this step, you added sample questions and quizzes to be displayed on the frontend.
Conclusion
In this tutorial, you created a Strapi quiz app. You made four content types: Question, Quiz, Answer, and Score content types. Next, you extended the create, find, and findOne Score controllers so that they can calculate score totals. To make routes for these content types accessible, you added allowed actions to their API plugins for public and authenticated roles on the User and Permissions plugin. Lastly, you created sample question and quiz content to be displayed on the frontend. In part 2, you will modify the Angular 13 frontend to allow for user signup and login and to track their scores.
To learn more about Strapi and ways you can customize it, checkout its website and its documentation.
Zara is a software developer and technical writer, using React Native, React, Rails, Node, Golang, Angular and many more technologies.