Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): add slug_id to posts #314

Merged
merged 5 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/backend/config/sync/admin-role.strapi-super-admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"scheduled_at",
"ghost_id",
"codeinjection_head",
"codeinjection_foot"
"codeinjection_foot",
"slug_id"
],
"locales": ["en"]
},
Expand Down Expand Up @@ -93,7 +94,8 @@
"scheduled_at",
"ghost_id",
"codeinjection_head",
"codeinjection_foot"
"codeinjection_foot",
"slug_id"
],
"locales": ["en"]
},
Expand All @@ -115,7 +117,8 @@
"scheduled_at",
"ghost_id",
"codeinjection_head",
"codeinjection_foot"
"codeinjection_foot",
"slug_id"
],
"locales": ["en"]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@
"sortable": true
}
},
"slug_id": {
"edit": {
"label": "slug_id",
"description": "",
"placeholder": "",
"visible": true,
"editable": true
},
"list": {
"label": "slug_id",
"searchable": true,
"sortable": true
}
},
"createdAt": {
"edit": {
"label": "createdAt",
Expand Down Expand Up @@ -294,6 +308,10 @@
{
"name": "codeinjection_foot",
"size": 6
},
{
"name": "slug_id",
"size": 6
}
]
]
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/config/sync/user-role.authenticated.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
{
"action": "api::post.post.findOne"
},
{
"action": "api::post.post.findOneBySlugId"
},
{
"action": "api::post.post.publish"
},
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/config/sync/user-role.contributor.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
{
"action": "api::post.post.findOne"
},
{
"action": "api::post.post.findOneBySlugId"
},
{
"action": "api::post.post.update"
},
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/api/post/content-types/post/lifecycles.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ module.exports = {
.service("api::post.post")
.validatePublishedAt(new Date(data.publishedAt));
}
// auto generate slug_id
data.slug_id = strapi.service("api::post.post").generateSlugId();
},
beforeUpdate(event) {
const {
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/src/api/post/content-types/post/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@
}
},
"type": "text"
},
"slug_id": {
"pluginOptions": {
"i18n": {
"localized": true
}
},
"type": "string",
"maxLength": 8,
"unique": true
}
}
}
19 changes: 18 additions & 1 deletion apps/backend/src/api/post/controllers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,25 @@ module.exports = createCoreController("api::post.post", ({ strapi }) => {
}
filters.author = [ctx.state.user.id];
ctx.query.filters = filters;

// call the default core action with modified ctx
return await super.find(ctx);
}
},
async findOneBySlugId(ctx) {
try {
// find id from slug_id
const postId = await strapi
.service("api::post.post")
.findIdBySlugId(ctx.request.params.slug_id);

ctx.request.params.id = postId;

// pass it onto default findOne controller
return await super.findOne(ctx);
} catch (err) {
ctx.body = err;
}
},
async create(ctx) {
if (!helpers.isEditor(ctx)) {
// don't allow publishing or scheduling posts
Expand Down Expand Up @@ -59,6 +73,9 @@ module.exports = createCoreController("api::post.post", ({ strapi }) => {
delete ctx.request.body.data.author;
}

// prevent updating the slug ID
delete ctx.request.body.data.slug_id;

// call the default core action with modified data
return await super.update(ctx);
},
Expand Down
34 changes: 34 additions & 0 deletions apps/backend/src/api/post/policies/is-own-post-slug-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use strict";

/**
* `is-own-post-slug-id` policy
*/

module.exports = async (policyContext, config, { strapi }) => {
const helpers = strapi.service("api::helpers.helpers");

// Editors can access any posts
if (helpers.isEditor(policyContext)) {
return true;
}

// Contributors can only access their own posts
try {
// find author id from slug_id
const posts = await strapi.entityService.findMany("api::post.post", {
filters: { slug_id: policyContext.params.slug_id },
fields: ["id"],
populate: ["author"],
});

if (posts[0].author.id !== policyContext.state.user.id) {
return false;
}
} catch (err) {
strapi.log.error("Error in is-own-post-slug-id policy.");
strapi.log.error(err);
return false;
}

return true;
};
13 changes: 13 additions & 0 deletions apps/backend/src/api/post/routes/0-post.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
// Routes files are loaded in alphabetical order.
// Using the filename starting with "0-" to load custom routes before core routes.

// Custom routes
module.exports = {
routes: [
{
method: "GET",
path: "/posts/slug_id/:slug_id",
handler: "post.findOneBySlugId",
config: {
policies: ["is-own-post-slug-id"],
middlewares: [],
},
},
{
method: "PATCH",
path: "/posts/:id/schedule",
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/api/post/routes/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

const { createCoreRouter } = require("@strapi/strapi").factories;

// Core routes
module.exports = createCoreRouter("api::post.post", {
config: {
findOne: {
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/api/post/services/post.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use strict";

const { ValidationError } = require("@strapi/utils").errors;
const { customAlphabet } = require("nanoid");

/**
* post service
Expand All @@ -9,6 +10,17 @@ const { ValidationError } = require("@strapi/utils").errors;
const { createCoreService } = require("@strapi/strapi").factories;

module.exports = createCoreService("api::post.post", ({ strapi }) => ({
// finds id from slug_id
// returns null if not found
async findIdBySlugId(slug_id) {
// Have to use findMany instead of fineOne to search by slug_id
const postIds = await strapi.entityService.findMany("api::post.post", {
filters: { slug_id: slug_id },
fields: ["id"],
});
return postIds.length > 0 ? postIds[0].id : null;
},

async create(reqBody = {}) {
if (process.env.DATA_MIGRATION === "true") {
reqBody.data.createdAt = reqBody.data.created_at;
Expand Down Expand Up @@ -52,4 +64,11 @@ module.exports = createCoreService("api::post.post", ({ strapi }) => ({
}
return true;
},

generateSlugId() {
// generate random 8 characters ID
const characterSet = "0123456789abcdefghijklmnopqrstuvwxyz";
const nanoid = customAlphabet(characterSet, 8);
return nanoid();
},
}));
66 changes: 65 additions & 1 deletion apps/backend/tests/post/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,38 @@ describe("post", () => {
expect(response.status).toBe(403);
});
});
describe("GET /posts/slug_id/:slug_id", () => {
it("should find post by slug_id", async () => {
// get slug_id from database
const post = await getPost("test-slug");

// find the post by slug_id through API
const response = await request(strapi.server.httpServer)
.get(`/api/posts/slug_id/${post.slug_id}`)
.set("Content-Type", "application/json")
.set("Authorization", `Bearer ${contributorJWT}`)
.send();

expect(response.status).toBe(200);
const responsePost = response.body.data.attributes;

expect(responsePost.slug_id).toEqual(post.slug_id);
expect(responsePost.slug).toEqual("test-slug");
});
it("should prevent contributors viewing other user's post by slug_id", async () => {
// get slug_id from database
const post = await getPost("editors-draft-post");

// find the post by slug_id through API
const response = await request(strapi.server.httpServer)
.get(`/api/posts/slug_id/${post.slug_id}`)
.set("Content-Type", "application/json")
.set("Authorization", `Bearer ${contributorJWT}`)
.send();

expect(response.status).toBe(403);
});
});
describe("POST /posts", () => {
it("should create post including publishedAt and scheduled_at for editors", async () => {
const response = await request(strapi.server.httpServer)
Expand Down Expand Up @@ -113,7 +145,7 @@ describe("post", () => {
});

it("should not set publishedAt to future date", async () => {
const postToCreateCopy = { ...postToCreate };
const postToCreateCopy = { data: { ...postToCreate.data } };
const now = new Date();
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
postToCreateCopy.data.publishedAt = oneHourFromNow;
Expand Down Expand Up @@ -152,6 +184,20 @@ describe("post", () => {
);
expect(postCreated.author.id).toBe(currentUser.id);
});

it("should auto generate slug_id", async () => {
const response = await request(strapi.server.httpServer)
.post("/api/posts")
.set("Content-Type", "application/json")
.set("Authorization", `Bearer ${contributorJWT}`)
.send(JSON.stringify(postToCreate));

expect(response.status).toBe(200);
const responsePost = response.body.data.attributes;

// slug_id should consist of 8 characters from the lowercase letters and numbers
expect(responsePost.slug_id).toMatch(/^[0-9a-z]{8}$/);
});
});

describe("PUT /posts/:id", () => {
Expand Down Expand Up @@ -306,6 +352,24 @@ describe("post", () => {
);
expect(postAfterRequest.title).toBe(newData.data.title);
});

it("should not change slug_id", async () => {
// get slug_id from database
const post = await getPost("test-slug");
const postCopy = { data: { ...post.data } };
postCopy.data.slug_id = "000000";

const response = await request(strapi.server.httpServer)
.put(`/api/posts/${post.id}`)
.set("Content-Type", "application/json")
.set("Authorization", `Bearer ${contributorJWT}`)
.send(JSON.stringify(postCopy));

expect(response.status).toBe(200);
const responsePost = response.body.data.attributes;

expect(responsePost.slug_id).toEqual(post.slug_id);
});
});
describe("PATCH /posts/:id/schedule", () => {
it("should schedule publishing a post", async () => {
Expand Down
Loading