diff --git a/backend/src/db/migrations/20231228152900-add-custom-fields.js b/backend/src/db/migrations/20231228152900-add-custom-fields.js new file mode 100644 index 0000000..9ce02dc --- /dev/null +++ b/backend/src/db/migrations/20231228152900-add-custom-fields.js @@ -0,0 +1,52 @@ +'use strict'; + +const TABLE_NAME = 'project_custom_fields'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(TABLE_NAME, { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + projectId: { + type: Sequelize.INTEGER, + field: 'project_id', + references: { + model: { + tableName: 'projects', + schema: 'public', + }, + key: 'id', + }, + }, + fieldName: { + type: Sequelize.STRING, + field: 'field_name', + }, + fieldType: { + type: Sequelize.STRING, + field: 'field_type', + }, + createdAt: { + field: 'created_at', + type: Sequelize.DATE, + }, + updatedAt: { + field: 'updated_at', + type: Sequelize.DATE, + }, + }); + + await queryInterface.addIndex(TABLE_NAME, { + fields: ['project_id'], + unique: false, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable(TABLE_NAME); + }, +}; diff --git a/backend/src/db/migrations/20231228153315-add-issue-custom-field-values.js b/backend/src/db/migrations/20231228153315-add-issue-custom-field-values.js new file mode 100644 index 0000000..48fafb5 --- /dev/null +++ b/backend/src/db/migrations/20231228153315-add-issue-custom-field-values.js @@ -0,0 +1,16 @@ +'use strict'; + +const TABLE_NAME = 'issues'; +const COLUMN_NAME = 'custom_fields'; + +/** @type {import('sequelize-cli').Migration} */ +export default { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, { + type: Sequelize.JSONB, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME); + }, +}; diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js index 1d5147a..08b6a55 100644 --- a/backend/src/db/models/index.js +++ b/backend/src/db/models/index.js @@ -1,20 +1,21 @@ 'use strict'; import { DataTypes, Sequelize } from 'sequelize'; -import { ENABLE_SEQUELIZE_LOGGING, SQL_URI } from '../../services/config.js'; -import User from './user.js'; -import Project from './project.js'; -import Board from './board.js'; -import IssueStatuses from './issue-statuses.js'; -import Issue from './issue.js'; +import { ENABLE_SEQUELIZE_LOGGING, SQL_URI } from '../../services/config.js'; import Asset from './asset.js'; -import IssueComment from './issue-comment.js'; -import ProjectTag from './project-tag.js'; -import IssueTag from './issue-tag.js'; +import Board from './board.js'; import IssueBoard from './issue-board.js'; +import IssueComment from './issue-comment.js'; import IssueLinks from './issue-links.js'; +import IssueStatuses from './issue-statuses.js'; +import IssueTag from './issue-tag.js'; +import Issue from './issue.js'; +import ProjectCustomFields from './project-custom-fields.js'; import ProjectPermissions from './project-permissions.js'; +import ProjectTag from './project-tag.js'; +import Project from './project.js'; +import User from './user.js'; export const db = {}; @@ -35,6 +36,7 @@ const init = async () => { db.IssueBoard = IssueBoard(db.sequelize, DataTypes); db.IssueLinks = IssueLinks(db.sequelize, DataTypes); db.ProjectPermissions = ProjectPermissions(db.sequelize, DataTypes); + db.ProjectCustomFields = ProjectCustomFields(db.sequelize, DataTypes); Object.values(db).forEach((model) => { if (model.associate) { diff --git a/backend/src/db/models/issue.js b/backend/src/db/models/issue.js index c5b0963..e57919c 100644 --- a/backend/src/db/models/issue.js +++ b/backend/src/db/models/issue.js @@ -75,6 +75,10 @@ export default (sequelize, DataTypes) => { type: DataTypes.TSVECTOR, field: 'vector_search', }, + customFields: { + type: DataTypes.JSONB, + field: 'custom_fields', + }, createdAt: { field: 'created_at', type: DataTypes.DATE, diff --git a/backend/src/db/models/project-custom-fields.js b/backend/src/db/models/project-custom-fields.js new file mode 100644 index 0000000..4188efd --- /dev/null +++ b/backend/src/db/models/project-custom-fields.js @@ -0,0 +1,51 @@ +'use strict'; + +export default (sequelize, DataTypes) => { + const ProjectCustomField = sequelize.define( + 'ProjectCustomField', + { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER, + }, + projectId: { + type: DataTypes.INTEGER, + field: 'project_id', + references: { + model: 'projects', + key: 'id', + }, + }, + fieldName: { + type: DataTypes.STRING, + field: 'field_name', + }, + fieldType: { + type: DataTypes.STRING, + field: 'field_type', + }, + createdAt: { + field: 'created_at', + type: DataTypes.DATE, + }, + updatedAt: { + field: 'updated_at', + type: DataTypes.DATE, + }, + }, + { + sequelize, + tableName: 'project_custom_fields', + timestamps: false, + indexes: [{ unique: false, fields: ['project_id'] }], + } + ); + + ProjectCustomField.associate = ({ Project }) => { + ProjectCustomField.belongsTo(Project, { foreignKey: 'project_id' }); + }; + + return ProjectCustomField; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 9c306e6..8da24ee 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -226,6 +226,8 @@ const myContextFunction = async (request) => { const token = request.headers.authorization; let user = null; + if (!token) return { db, user }; + // Allow if introspection query only if (!Array.isArray(request.body) && request?.body?.query?.includes('IntrospectionQuery')) { return { diff --git a/backend/src/resolvers/Issue/index.js b/backend/src/resolvers/Issue/index.js index e0b9d98..9cf7af5 100644 --- a/backend/src/resolvers/Issue/index.js +++ b/backend/src/resolvers/Issue/index.js @@ -1,4 +1,6 @@ +import { keyBy, merge, values } from 'lodash-es'; import { Op } from 'sequelize'; +import yn from 'yn'; import { websocketBroadcast } from '../../services/ws-server.js'; @@ -119,7 +121,19 @@ const resolvers = { return { message: 'success', status: 'success' }; }, updateIssue: async (parent, { input }, { db, websocketServer }) => { - const { id, issueStatusId, assigneeId, reporterId, title, description, tagIds, priority, archived } = input; + const { + id, + issueStatusId, + assigneeId, + reporterId, + title, + description, + tagIds, + priority, + archived, + customFieldId, + customFieldValue, + } = input; const issue = await db.sequelize.models.Issue.findByPk(id); @@ -144,6 +158,28 @@ const resolvers = { if (issue.assigneeId === 0) issue.assigneeId = null; if (issue.reporterId === 0) issue.reporterId = null; + if (customFieldId && customFieldValue) { + const customField = await db.sequelize.models.ProjectCustomField.findByPk(Number(customFieldId)); + if (!customField) throw new Error('Custom field not found'); + + let valueCasted = customFieldValue; + + if (customField.fieldType.toLowerCase() === 'number') valueCasted = Number(customFieldValue); + else if (customField.fieldType.toLowerCase() === 'boolean') valueCasted = yn(customFieldValue); + + const customFieldObject = { + id: `${issue.id}-${customField.id}`, + customFieldId, + value: valueCasted, + createdAt: new Date(), // TODO: improve date format decision + }; + + // TODO: investigate how to deep set the value instead of this to leverage DB level updating + issue.customFields = issue.customFields + ? values(merge(keyBy(issue.customFields, 'id'), keyBy([customFieldObject], 'id'))) + : [customFieldObject]; + } + await issue.save(); const issueStatus = await db.sequelize.models.IssueStatuses.findByPk(issueStatusId ?? issue.issueStatusId); @@ -202,6 +238,11 @@ const resolvers = { return { message: 'issue deleted', status: 'success' }; }, }, + CustomFieldValue: { + customField: async (parent, args, { db }) => { + return db.sequelize.models.ProjectCustomField.findByPk(parent.customFieldId); + }, + }, Issue: { links: async (parent, args, { db }) => { return [ diff --git a/backend/src/resolvers/Project/index.js b/backend/src/resolvers/Project/index.js index d1991ee..bdfaeb9 100644 --- a/backend/src/resolvers/Project/index.js +++ b/backend/src/resolvers/Project/index.js @@ -63,6 +63,27 @@ const resolvers = { }, }, Mutation: { + createProjectCustomField: async (parent, { input: { projectId, fieldName, fieldType } }, { db }) => + await db.sequelize.models.ProjectCustomField.create( + { + projectId: Number(projectId), + fieldName, + fieldType, + }, + { returning: true } + ), + deleteProjectCustomField: async (parent, { input: { id } }, { db }) => { + const findCustomField = await db.sequelize.models.ProjectCustomField.findByPk(id); + + if (!findCustomField) throw new Error('Custom field not found'); + + await findCustomField.destroy(); + + return { + message: 'deleted custom field', + status: 'success', + }; + }, createProjectTag: async (parent, { input }, { db }) => { const { projectId, name } = input; @@ -71,9 +92,7 @@ const resolvers = { name, }); }, - deleteProjectTag: async (parent, { input }, { db }) => { - const { id } = input; - + deleteProjectTag: async (parent, { input: { id } }, { db }) => { const findProjectTag = await db.sequelize.models.ProjectTag.findByPk(id); if (!findProjectTag) { @@ -162,6 +181,9 @@ const resolvers = { }, }, Project: { + customFields: (parent, args, { db }) => { + return db.sequelize.models.ProjectCustomField.findAll({ where: { projectId: parent.id } }); + }, tags: (parent, args, { db }) => { return db.sequelize.models.ProjectTag.findAll({ where: { projectId: parent.id } }); }, diff --git a/backend/src/type-defs.js b/backend/src/type-defs.js index 42f03b1..38e0f7d 100644 --- a/backend/src/type-defs.js +++ b/backend/src/type-defs.js @@ -12,6 +12,13 @@ const typeDefs = gql` PRIVATE } + enum CUSTOM_FIELD_TYPE { + TEXT + NUMBER + DATE + BOOLEAN + } + enum Order { ASC DESC @@ -77,6 +84,25 @@ const typeDefs = gql` tags: [ProjectTag] users: [User] issueCount: Int + customFields: [CustomField] + } + + type CustomField { + id: ID! + projectId: String! + fieldName: String! + fieldType: CUSTOM_FIELD_TYPE! + createdAt: String + updatedAt: String + } + + type CustomFieldValue { + id: ID! + customFieldId: String! + customField: CustomField + value: String! + createdAt: String + updatedAt: String } type Issue { @@ -98,6 +124,8 @@ const typeDefs = gql` links: [Issue] linkType: String linkedIssueId: String + + customFields: [CustomFieldValue] } type Column { @@ -215,6 +243,11 @@ const typeDefs = gql` priority: Int archived: Boolean tagIds: [String] + + # This represents the ID of the project custom field id + customFieldId: String + # We will transform this value based on the custom field type + customFieldValue: String } input DeleteIssueInput { @@ -328,6 +361,16 @@ const typeDefs = gql` projectId: String! } + input CreateProjectCustomFieldInput { + projectId: String! + fieldName: String! + fieldType: CUSTOM_FIELD_TYPE! + } + + input DeleteProjectCustomFieldInput { + id: String! + } + # Mutations type Mutation { createProject(input: CreateProjectInput): Project @@ -338,6 +381,9 @@ const typeDefs = gql` createProjectTag(input: CreateProjectTagInput!): ProjectTag deleteProjectTag(input: DeleteProjectTagInput!): MessageAndStatus + createProjectCustomField(input: CreateProjectCustomFieldInput!): CustomField + deleteProjectCustomField(input: DeleteProjectCustomFieldInput!): MessageAndStatus + createBoard(input: CreateBoardInput): Board updateBoard(input: UpdateBoardInput): Board