diff --git a/infra/migrations/1688768453152_alter-table-users-add-description.js b/infra/migrations/1688768453152_alter-table-users-add-description.js new file mode 100644 index 000000000..c08b4e03a --- /dev/null +++ b/infra/migrations/1688768453152_alter-table-users-add-description.js @@ -0,0 +1,12 @@ +exports.up = async (pgm) => { + await pgm.addColumns('users', { + description: { + type: 'text', + check: 'length(description) <= 5000', + notNull: true, + default: '', + }, + }); +}; + +exports.down = false; diff --git a/models/authorization.js b/models/authorization.js index 7034430b9..22596f4f4 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -89,6 +89,7 @@ function filterInput(user, feature, input) { username: input.username, email: input.email, password: input.password, + description: input.description, notifications: input.notifications, }; } @@ -175,6 +176,7 @@ function filterOutput(user, feature, output) { filteredOutputValues = { id: output.id, username: output.username, + description: output.description, features: output.features, tabcoins: output.tabcoins, tabcash: output.tabcash, @@ -189,6 +191,7 @@ function filterOutput(user, feature, output) { id: output.id, username: output.username, email: output.email, + description: output.description, notifications: output.notifications, features: output.features, tabcoins: output.tabcoins, @@ -203,6 +206,7 @@ function filterOutput(user, feature, output) { filteredOutputValues = output.map((user) => ({ id: user.id, username: user.username, + description: user.description, features: user.features, tabcoins: user.tabcoins, tabcash: user.tabcash, diff --git a/models/user.js b/models/user.js index df8640455..0e21026d2 100644 --- a/models/user.js +++ b/models/user.js @@ -249,7 +249,8 @@ async function update(username, postedUserData, options = {}) { username = $2, email = $3, password = $4, - notifications = $5, + description = $5, + notifications = $6, updated_at = (now() at time zone 'utc') WHERE id = $1 @@ -261,6 +262,7 @@ async function update(username, postedUserData, options = {}) { userWithUpdatedValues.username, userWithUpdatedValues.email, userWithUpdatedValues.password, + userWithUpdatedValues.description, userWithUpdatedValues.notifications, ], }; @@ -286,6 +288,7 @@ async function validatePatchSchema(postedUserData) { username: 'optional', email: 'optional', password: 'optional', + description: 'optional', notifications: 'optional', }); diff --git a/models/validator.js b/models/validator.js index 0327729b6..4950da63d 100644 --- a/models/validator.js +++ b/models/validator.js @@ -154,6 +154,23 @@ const schemas = { }); }, + description: function () { + return Joi.object({ + description: Joi.string() + .replace(/(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+$|\u0000/gsu, '') + .max(5000) + .invalid(null) + .allow('') + .when('$required.description', { is: 'required', then: Joi.required(), otherwise: Joi.optional() }) + .messages({ + 'any.required': `"description" é um campo obrigatório.`, + 'string.base': `"description" deve ser do tipo String.`, + 'string.max': `"description" deve conter no máximo {#limit} caracteres.`, + 'any.invalid': `"description" possui o valor inválido "null".`, + }), + }); + }, + notifications: function () { return Joi.object({ notifications: Joi.boolean() @@ -867,6 +884,8 @@ const reservedUsernames = [ 'dados', 'dashboard', 'desconectar', + 'descricao', + 'description', 'deslogar', 'diretrizes', 'discussao', diff --git a/pages/[username]/index.public.js b/pages/[username]/index.public.js index eb5448981..df7ffa6d7 100644 --- a/pages/[username]/index.public.js +++ b/pages/[username]/index.public.js @@ -9,6 +9,7 @@ import { Label, LabelGroup, Pagehead, + Viewer, useConfirm, } from '@/TabNewsUI'; import { KebabHorizontalIcon, TrashIcon } from '@primer/octicons-react'; @@ -86,12 +87,11 @@ export default function Home({ contentListFound, pagination, userFound: userFoun function OptionsMenu() { return ( - + - {!userFound?.features?.includes('nuked') && ( @@ -129,13 +129,28 @@ export default function Home({ contentListFound, pagination, userFound: userFoun )} - - + + {userFound.username} {user?.features?.includes('ban:user') && OptionsMenu()} + {userFound.description && ( + + + + )} + + Editar Perfil - ); @@ -56,6 +56,7 @@ function EditProfileForm() { const [isLoading, setIsLoading] = useState(false); const [errorObject, setErrorObject] = useState(undefined); const [emailDisabled, setEmailDisabled] = useState(false); + const [description, setDescription] = useState(user?.description || ''); function clearErrors() { setErrorObject(undefined); @@ -106,6 +107,10 @@ function EditProfileForm() { payload.email = email; } + if (user.description !== description) { + payload.description = description; + } + if (user.notifications !== notifications) { payload.notifications = notifications; } @@ -235,6 +240,24 @@ function EditProfileForm() { )} + + Descrição + + { + clearErrors(); + setDescription(value); + }} + value={description} + isValid={errorObject?.key === 'description'} + compact={true} + /> + + {errorObject?.key === 'description' && errorObject?.type === 'string.max' && ( + {errorObject.message} + )} + + Receber notificações por email diff --git a/tests/integration/api/v1/_use-cases/registration-flow.test.js b/tests/integration/api/v1/_use-cases/registration-flow.test.js index 3f3504c9d..75029ca0b 100644 --- a/tests/integration/api/v1/_use-cases/registration-flow.test.js +++ b/tests/integration/api/v1/_use-cases/registration-flow.test.js @@ -148,6 +148,7 @@ describe('Use case: Registration Flow (all successfully)', () => { id: postUserResponseBody.id, username: postUserResponseBody.username, email: 'regularregistrationflow@gmail.com', + description: '', notifications: true, features: [ 'create:session', diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 4de3f34e0..abc302956 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -121,6 +121,7 @@ describe('GET /api/v1/user', () => { expect(responseBody).toStrictEqual({ id: defaultUser.id, username: defaultUser.username, + description: defaultUser.description, email: defaultUser.email, notifications: defaultUser.notifications, features: defaultUser.features, @@ -238,6 +239,7 @@ describe('GET /api/v1/user', () => { expect(responseBody).toStrictEqual({ id: defaultUser.id, username: defaultUser.username, + description: defaultUser.description, email: defaultUser.email, notifications: defaultUser.notifications, features: defaultUser.features, @@ -291,6 +293,7 @@ describe('GET /api/v1/user', () => { expect(responseBody).toStrictEqual({ id: defaultUser.id, username: defaultUser.username, + description: defaultUser.description, email: defaultUser.email, notifications: defaultUser.notifications, features: defaultUser.features, @@ -343,6 +346,7 @@ describe('GET /api/v1/user', () => { expect(responseBody).toStrictEqual({ id: defaultUser.id, username: defaultUser.username, + description: defaultUser.description, email: defaultUser.email, notifications: defaultUser.notifications, features: defaultUser.features, diff --git a/tests/integration/api/v1/users/[username]/delete.test.js b/tests/integration/api/v1/users/[username]/delete.test.js index 3537ac096..1067a7697 100644 --- a/tests/integration/api/v1/users/[username]/delete.test.js +++ b/tests/integration/api/v1/users/[username]/delete.test.js @@ -423,6 +423,7 @@ describe('DELETE /api/v1/users/[username]', () => { expect(nukeResponseBody).toStrictEqual({ id: secondUser.id, username: secondUser.username, + description: secondUser.description, features: ['nuked'], tabcoins: 0, tabcash: 0, @@ -550,6 +551,7 @@ describe('DELETE /api/v1/users/[username]', () => { expect(nuke1ResponseBody).toStrictEqual({ id: secondUser.id, username: secondUser.username, + description: secondUser.description, features: ['nuked'], tabcoins: 0, tabcash: 0, diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 88ffef4d7..9589edcdb 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -89,6 +89,7 @@ describe('GET /api/v1/users/[username]', () => { expect(responseBody).toStrictEqual({ id: userCreated.id, username: 'userNameToBeFound', + description: userCreated.description, features: userCreated.features, tabcoins: userCreated.tabcoins, tabcash: userCreated.tabcash, @@ -117,6 +118,7 @@ describe('GET /api/v1/users/[username]', () => { expect(responseBody).toStrictEqual({ id: userCreated.id, username: 'userNameToBeFoundCAPS', + description: userCreated.description, features: userCreated.features, tabcoins: userCreated.tabcoins, tabcash: userCreated.tabcash, diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 0bf111c90..88ca51136 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -97,6 +97,7 @@ describe('PATCH /api/v1/users/[username]', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'regularUserPatchingHisUsername', + description: defaultUser.description, features: defaultUser.features, tabcoins: 0, tabcash: 0, @@ -141,6 +142,7 @@ describe('PATCH /api/v1/users/[username]', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'REGULARUser', + description: defaultUser.description, features: defaultUser.features, tabcoins: 0, tabcash: 0, @@ -183,6 +185,7 @@ describe('PATCH /api/v1/users/[username]', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'untrimmedUsername', + description: defaultUser.description, features: defaultUser.features, tabcoins: 0, tabcash: 0, @@ -592,6 +595,103 @@ describe('PATCH /api/v1/users/[username]', () => { expect(confirmationEmail.text.includes(emailConfirmationPageEndpoint)).toBe(true); }); + test('Patching itselt with a "description" containing a valid value', async () => { + const defaultUser = await orchestrator.createUser(); + await orchestrator.activateUser(defaultUser); + const defaultUserSession = await orchestrator.createSession(defaultUser); + + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users/${defaultUser.username}`, { + method: 'patch', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${defaultUserSession.token}`, + }, + + body: JSON.stringify({ + description: 'my description', + }), + }); + + const responseBody = await response.json(); + expect(response.status).toEqual(200); + expect(responseBody).toStrictEqual({ + id: responseBody.id, + username: responseBody.username, + description: 'my description', + features: [ + 'create:session', + 'read:session', + 'create:content', + 'create:content:text_root', + 'create:content:text_child', + 'update:content', + 'update:user', + ], + tabcoins: 0, + tabcash: 0, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + }); + + test('Patching itselt with a "description" containing more than 5.000 characters', async () => { + const defaultUser = await orchestrator.createUser(); + await orchestrator.activateUser(defaultUser); + const defaultUserSession = await orchestrator.createSession(defaultUser); + + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users/${defaultUser.username}`, { + method: 'patch', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${defaultUserSession.token}`, + }, + + body: JSON.stringify({ + description: 'a'.repeat(5001), + }), + }); + + const responseBody = await response.json(); + expect(response.status).toEqual(400); + expect(responseBody.status_code).toEqual(400); + expect(responseBody.name).toEqual('ValidationError'); + expect(responseBody.message).toEqual('"description" deve conter no máximo 5000 caracteres.'); + expect(responseBody.action).toEqual('Ajuste os dados enviados e tente novamente.'); + expect(responseBody.type).toEqual('string.max'); + expect(uuidVersion(responseBody.error_id)).toEqual(4); + expect(uuidVersion(responseBody.request_id)).toEqual(4); + expect(responseBody.error_location_code).toEqual('MODEL:VALIDATOR:FINAL_SCHEMA'); + }); + + test('Patching itselt with a "description" containing value null', async () => { + const defaultUser = await orchestrator.createUser(); + await orchestrator.activateUser(defaultUser); + const defaultUserSession = await orchestrator.createSession(defaultUser); + + const response = await fetch(`${orchestrator.webserverUrl}/api/v1/users/${defaultUser.username}`, { + method: 'patch', + headers: { + 'Content-Type': 'application/json', + cookie: `session_id=${defaultUserSession.token}`, + }, + + body: JSON.stringify({ + description: null, + }), + }); + + const responseBody = await response.json(); + expect(response.status).toEqual(400); + expect(responseBody.status_code).toEqual(400); + expect(responseBody.name).toEqual('ValidationError'); + expect(responseBody.message).toEqual('"description" possui o valor inválido "null".'); + expect(responseBody.action).toEqual('Ajuste os dados enviados e tente novamente.'); + expect(responseBody.type).toEqual('any.invalid'); + expect(uuidVersion(responseBody.error_id)).toEqual(4); + expect(uuidVersion(responseBody.request_id)).toEqual(4); + expect(responseBody.error_location_code).toEqual('MODEL:VALIDATOR:FINAL_SCHEMA'); + }); + describe('TEMPORARY BEHAVIOR', () => { test('Patching itself with another "password"', async () => { let defaultUser = await orchestrator.createUser({ diff --git a/tests/integration/api/v1/users/get.test.js b/tests/integration/api/v1/users/get.test.js index 3f353ae5c..ee059d8f3 100644 --- a/tests/integration/api/v1/users/get.test.js +++ b/tests/integration/api/v1/users/get.test.js @@ -74,6 +74,7 @@ describe('GET /api/v1/users', () => { const firstUser = { id: defaultUser.id, username: defaultUser.username, + description: defaultUser.description, features: defaultUser.features, tabcoins: 0, tabcash: 0, @@ -83,6 +84,7 @@ describe('GET /api/v1/users', () => { const secondUser = { id: privilegedUser.id, username: privilegedUser.username, + description: privilegedUser.description, features: privilegedUser.features, tabcoins: 0, tabcash: 0, diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 72eae3932..b9d4f3ad1 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,6 +31,7 @@ describe('POST /api/v1/users', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'uniqueUserName', + description: '', features: ['read:activation_token'], tabcoins: 0, tabcash: 0, @@ -72,6 +73,7 @@ describe('POST /api/v1/users', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'postWithUnknownKey', + description: '', features: ['read:activation_token'], tabcoins: 0, tabcash: 0, @@ -107,6 +109,7 @@ describe('POST /api/v1/users', () => { expect(responseBody).toStrictEqual({ id: responseBody.id, username: 'extraSpaceInTheEnd', + description: '', features: ['read:activation_token'], tabcoins: 0, tabcash: 0, diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 472d63e4d..59c012912 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -113,6 +113,7 @@ async function createUser(userObject) { username: userObject?.username || faker.internet.userName().replace('_', '').replace('.', ''), email: userObject?.email || faker.internet.email(), password: userObject?.password || 'password', + description: userObject?.description || '', }); }