diff --git a/CHANGELOG.md b/CHANGELOG.md index 099d2a90a..dae3f6ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [4.2.0] - 2022-08-19 + +### Added +- Add, delete and update Custom Views for groups that can either be a link to an external URL or a filtered view of posts in a group +- Project management link and Donations link added to posts (only used for projects right now) +- Query for non-logged in users to be able to look up public posts + +### Fixed +- Bugs that could unset a location or area polygon when saving settings + ## [4.1.5] - 2022-07-05 ### Fixed diff --git a/api/graphql/index.js b/api/graphql/index.js index 256ae6b51..cf489572a 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -157,6 +157,7 @@ export function makePublicQueries (userId, fetchOne, fetchMany) { // Can only access public communities and posts group: async (root, { id, slug }) => fetchOne('Group', slug || id, slug ? 'slug' : 'id', { visibility: Group.Visibility.PUBLIC }), groups: (root, args) => fetchMany('Group', Object.assign(args, { visibility: Group.Visibility.PUBLIC })), + post: (root, { id }) => fetchOne('Post', id, 'id', { isPublic: true }), posts: (root, args) => fetchMany('Post', Object.assign(args, { isPublic: true })) } } diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 9d335ce86..7d79fd9a6 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -155,16 +155,18 @@ export default async function makeModels (userId, isAdmin, apiClient) { Post: { model: Post, attributes: [ + 'accept_contributions', + 'announcement', 'created_at', - 'updated_at', - 'fulfilled_at', + 'donations_link', 'end_time', - 'start_time', - 'location', - 'announcement', - 'accept_contributions', + 'fulfilled_at', 'is_public', - 'type' + 'location', + 'project_management_link', + 'start_time', + 'type', + 'updated_at' ], getters: { commenters: (p, { first }) => p.getCommenters(first, userId), @@ -176,25 +178,28 @@ export default async function makeModels (userId, isAdmin, apiClient) { : '' }, relations: [ - {comments: {querySet: true}}, + { comments: { querySet: true } }, 'groups', - {user: {alias: 'creator'}}, + { user: { alias: 'creator' } }, 'followers', 'locationObject', - {members: {querySet: true}}, - {eventInvitations: {querySet: true}}, + { members: { querySet: true } }, + { eventInvitations: { querySet: true } }, 'linkPreview', 'postMemberships', - {media: { - alias: 'attachments', - arguments: ({ type }) => [type] - }}, - {tags: {alias: 'topics'}} + { + media: { + alias: 'attachments', + arguments: ({ type }) => [type] + } + }, + { tags: { alias: 'topics' } } ], filter: postFilter(userId, isAdmin), isDefaultTypeForTable: true, - fetchMany: ({ afterTime, beforeTime, boundingBox, context, filter, first, groupSlugs, isFulfilled, offset, order, sortBy, search, topic, topics, types }) => + fetchMany: ({ activePostsOnly = false, afterTime, beforeTime, boundingBox, context, filter, first, groupSlugs, isFulfilled, offset, order, sortBy, search, topic, topics, types }) => searchQuerySet('posts', { + activePostsOnly, afterTime, beforeTime, boundingBox, @@ -236,6 +241,7 @@ export default async function makeModels (userId, isAdmin, apiClient) { relations: [ {activeMembers: { querySet: true }}, {childGroups: {querySet: true}}, + {customViews: {querySet: true}}, {groupRelationshipInvitesFrom: {querySet: true}}, {groupRelationshipInvitesTo: {querySet: true}}, {groupTags: { @@ -260,8 +266,9 @@ export default async function makeModels (userId, isAdmin, apiClient) { {parentGroups: {querySet: true}}, {posts: { querySet: true, - filter: (relation, { afterTime, beforeTime, boundingBox, filter, isAnnouncement, isFulfilled, order, search, sortBy, topic, topics, types }) => + filter: (relation, { activePostsOnly = false, afterTime, beforeTime, boundingBox, filter, isAnnouncement, isFulfilled, order, search, sortBy, topic, topics, types }) => relation.query(filterAndSortPosts({ + activePostsOnly, afterTime, beforeTime, boundingBox, @@ -303,8 +310,9 @@ export default async function makeModels (userId, isAdmin, apiClient) { {viewPosts: { querySet: true, arguments: () => [userId], - filter: (relation, { afterTime, beforeTime, boundingBox, filter, isFulfilled, order, search, sortBy, topic, topics, types }) => + filter: (relation, { activePostsOnly = false, afterTime, beforeTime, boundingBox, filter, isFulfilled, order, search, sortBy, topic, topics, types }) => relation.query(filterAndSortPosts({ + activePostsOnly, afterTime, beforeTime, boundingBox, @@ -438,7 +446,7 @@ export default async function makeModels (userId, isAdmin, apiClient) { 'created_at', 'status', 'type', - 'updated_at', + 'updated_at' ], getters: { questionAnswers: i => i.questionAnswers().fetch() @@ -446,6 +454,26 @@ export default async function makeModels (userId, isAdmin, apiClient) { relations: ['createdBy', 'fromGroup', 'toGroup'] }, + CustomView: { + model: CustomView, + attributes: [ + 'group_id', + 'is_active', + 'search_text', + 'icon', + 'name', + 'external_link', + 'view_mode', + 'active_posts_only', + 'post_types', + 'order' + ], + relations: [ + 'group', + { tags: { alias: 'topics' } } + ] + }, + Invitation: { model: Invitation, attributes: [ @@ -458,7 +486,7 @@ export default async function makeModels (userId, isAdmin, apiClient) { relations: [ 'creator', 'group' - ], + ] }, JoinRequest: { diff --git a/api/graphql/mutations/group.js b/api/graphql/mutations/group.js index 575787cd9..e67378d81 100644 --- a/api/graphql/mutations/group.js +++ b/api/graphql/mutations/group.js @@ -115,6 +115,7 @@ export async function removeModerator (userId, personId, groupId, isRemoveFromGr export async function updateGroup (userId, groupId, changes) { const group = await getModeratedGroup(userId, groupId) + return group.update(convertGraphqlData(changes)) } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 8bbfceff9..6036ef5d3 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -130,6 +130,7 @@ type Person { moderatedGroupMemberships(first: Int, cursor: ID, order: String): [Membership] moderatedGroupMembershipsTotal: Int posts( + activePostsOnly: Boolean, afterTime: Date, beforeTime: Date, boundingBox: [PointInput], @@ -197,6 +198,7 @@ type Group { autocomplete: String ): GroupQuerySet createdAt: Date + customViews: CustomViewQuerySet description: String geoShape: JSON groupExtensions: GroupExtensionQuerySet @@ -245,6 +247,7 @@ type Group { pendingInvitations(first: Int, cursor: ID, order: String): InvitationQuerySet prerequisiteGroups(onlyNotMember: Boolean): GroupQuerySet posts( + activePostsOnly: Boolean, afterTime: Date, beforeTime: Date, boundingBox: [PointInput], @@ -273,6 +276,7 @@ type Group { typeDescriptor: String typeDescriptorPlural: String viewPosts( + activePostsOnly: Boolean, afterTime: Date, beforeTime: Date, boundingBox: [PointInput], @@ -345,6 +349,29 @@ type Widget { name: String } +type CustomView { + id: Int + name: String + groupId: Int + group: Group + isActive: Boolean + searchText: String + icon: String + externalLink: String + viewMode: String + order: Int + activePostsOnly: Boolean + postTypes: [String] + topics: [Topic] + topicsTotal: Int +} + +type CustomViewQuerySet { + total: Int + hasMore: Boolean + items: [CustomView] +} + type GroupRelationship { id: ID childGroup: Group @@ -447,42 +474,44 @@ type Interstitial { type Post { id: ID - title: String - details: String - type: String - createdAt: Date - updatedAt: Date - fulfilledAt: Date - startTime: Date - endTime: Date - location: String - locationObject: Location - isPublic: Boolean - creator: Person - followers(first: Int, cursor: ID, order: String): [Person] - followersTotal: Int + acceptContributions: Boolean activeMembers(first: Int, cursor: ID, order: String): PersonQuerySet - members(first: Int, cursor: ID, order: String): PersonQuerySet - eventInvitations(first: Int, cursor: ID, order: String): EventInvitationQuerySet - groups(first: Int, cursor: ID, order: String): [Group] - groupsTotal: Int + announcement: Boolean + attachments(type: String): [Attachment] + attachmentsTotal: Int comments(first: Int, cursor: ID, order: String): CommentQuerySet commenters(first: Int): [Person] commentersTotal: Int commentsTotal: Int + createdAt: Date + creator: Person + details: String + donationsLink: String + endTime: Date + eventInvitations(first: Int, cursor: ID, order: String): EventInvitationQuerySet + groups(first: Int, cursor: ID, order: String): [Group] + groupsTotal: Int + followers(first: Int, cursor: ID, order: String): [Person] + followersTotal: Int + fulfilledAt: Date linkPreview: LinkPreview - votesTotal: Int + location: String + locationObject: Location myVote: Boolean - attachments(type: String): [Attachment] - attachmentsTotal: Int postMemberships: [PostMembership] - postMembershipsTotal: Int, - topics: [Topic], + postMembershipsTotal: Int + projectManagementLink: String + isPublic: Boolean + members(first: Int, cursor: ID, order: String): PersonQuerySet + myEventResponse: String + startTime: Date + title: String + topics: [Topic] topicsTotal: Int - announcement: Boolean - acceptContributions: Boolean totalContributions: Int - myEventResponse: String + type: String + updatedAt: Date + votesTotal: Int } type PostMembership { @@ -678,6 +707,7 @@ type Query { messageThread(id: ID): MessageThread post(id: ID): Post posts( + activePostsOnly: Boolean, afterTime: Date, beforeTime: Date, boundingBox: [PointInput], @@ -782,23 +812,25 @@ input MeInput { } input PostInput { - title: String + acceptContributions: Boolean + announcement: Boolean details: String - type: String + donationsLink: String + endTime: Date + eventInviteeIds: [ID] + fileUrls: [String] groupIds: [String] - linkPreviewId: String + imageUrls: [String] isPublic: Boolean + linkPreviewId: String location: String locationId: ID - imageUrls: [String] - fileUrls: [String] - announcement: Boolean - topicNames: [String] memberIds: [ID] - acceptContributions: Boolean - eventInviteeIds: [ID] + projectManagementLink: String startTime: Date - endTime: Date + title: String + topicNames: [String] + type: String } input CommentInput { @@ -874,6 +906,7 @@ input GroupInput { active: Boolean avatarUrl: String bannerUrl: String + customViews: [CustomViewInput] description: String geoShape: String groupToGroupJoinQuestions: [QuestionInput] @@ -912,6 +945,26 @@ input GroupExtensionInput { type: String } +input CustomViewInput { + id: ID + name: String + groupId: Int + isActive: Boolean + searchText: String + icon: String + externalLink: String + viewMode: String + activePostsOnly: Boolean + postTypes: [String] + order: Int + topics: [CustomViewTopicInput] +} + +input CustomViewTopicInput { + id: ID + name: String +} + input QuestionInput { id: Int questionId: Int diff --git a/api/models/CustomView.js b/api/models/CustomView.js new file mode 100644 index 000000000..5a2070d23 --- /dev/null +++ b/api/models/CustomView.js @@ -0,0 +1,50 @@ +import { isEmpty, isEqual, difference } from 'lodash' + +module.exports = bookshelf.Model.extend(Object.assign({ + tableName: 'custom_views', + requireFetch: false, + + initialize() { + this.on('destroying', function(model, options) { + options.require = false + if (options.transacting) { + CustomViewTopic.where({ custom_view_id: this.id }).destroy(options) + } else { + bookshelf.knex.transaction(transacting => CustomViewTopic.where({ custom_view_id: this.id }).destroy({ ...options, transacting })) + } + }) + }, + + group () { + return this.belongsTo(Group) + }, + + tags () { + return this.belongsToMany(Tag).through(CustomViewTopic) + }, + + async updateTopics(topics, transacting) { + const newTopicIds = topics ? await Promise.map(topics, async (t) => parseInt(t.id) || parseInt((await Tag.findOrCreate(t.name, { transacting })).id)) : [] + const existingTopics = (await CustomViewTopic.query(q => q.select('tag_id').where('custom_view_id', this.id)).fetchAll({ transacting })) + const existingTopicIds = existingTopics.map(t => parseInt(t.get('tag_id'))) + + if (!isEqual(newTopicIds, existingTopicIds)) { + const topicsToAdd = difference(newTopicIds, existingTopicIds) + const topicsToRemove = difference(existingTopicIds, newTopicIds) + + await Promise.map(topicsToAdd, async (id) => { + await CustomViewTopic.create({ tag_id: id, custom_view_id: this.id }, transacting) + }) + + await Promise.map(topicsToRemove, async (id) => { + await CustomViewTopic.where({ tag_id: id, custom_view_id: this.id }).destroy({ require: false, transacting }) + }) + } + } +}), { + find (id, opts = {}) { + if (!id) return Promise.resolve(null) + const where = { id } + return this.where(where).fetch(opts) + } +}) diff --git a/api/models/CustomViewTopic.js b/api/models/CustomViewTopic.js new file mode 100644 index 000000000..19c457e34 --- /dev/null +++ b/api/models/CustomViewTopic.js @@ -0,0 +1,20 @@ +module.exports = bookshelf.Model.extend(Object.assign({ + tableName: 'custom_view_topics', + requireFetch: false, + + customView: function () { + return this.belongsTo(Post) + }, + + tag: function () { + return this.belongsTo(Tag) + } +}), { + create: function (attributes, transacting = false) { + const options = {} + if (transacting) { + options['transacting'] = transacting + } + return this.forge(attributes).save({}, options) + } +}) diff --git a/api/models/Group.js b/api/models/Group.js index 430bbe879..dd34895aa 100644 --- a/api/models/Group.js +++ b/api/models/Group.js @@ -89,6 +89,10 @@ module.exports = bookshelf.Model.extend(merge({ return this.belongsTo(User, 'created_by_id') }, + customViews () { + return this.hasMany(CustomView) + }, + groupRelationshipInvitesFrom () { return this.hasMany(GroupRelationshipInvite, 'from_group_id') .query({ where: { status: GroupRelationshipInvite.STATUS.Pending }}) @@ -379,67 +383,105 @@ module.exports = bookshelf.Model.extend(merge({ saneAttrs.settings = merge({}, this.get('settings'), attributes.settings) } - saneAttrs.location_id = isEmpty(saneAttrs.location_id) ? null : saneAttrs.location_id + // If location_id is explicitly set to something empty then set it to null + // Otherwise leave it alone + saneAttrs.location_id = saneAttrs.hasOwnProperty('location_id') && isEmpty(saneAttrs.location_id) ? null : saneAttrs.location_id // Make sure geometry column goes into the database correctly, converting from GeoJSON if (!isEmpty(attributes.geo_shape)) { const st = knexPostgis(bookshelf.knex) saneAttrs.geo_shape = st.geomFromGeoJSON(attributes.geo_shape) - } else { + } else if (saneAttrs.hasOwnProperty('geo_shape')) { + // if geo_shape is explicitly set to an empty value then unset it saneAttrs.geo_shape = null } - // If a new location is being passed in but not a new location_id then we geocode on the server - if (changes.location && changes.location !== this.get('location') && !changes.location_id) { - await Queue.classMethod('Group', 'geocodeLocation', { groupId: this.id }) - } + this.set(saneAttrs) + await this.validate() - if (changes.group_to_group_join_questions) { - const questions = await Promise.map(changes.group_to_group_join_questions.filter(jq => trim(jq.text) !== ''), async (jq) => { - return (await Question.where({ text: trim(jq.text) }).fetch()) || (await Question.forge({ text: trim(jq.text) }).save()) - }) - await GroupToGroupJoinQuestion.where({ group_id: this.id }).destroy({ require: false }) - for (let q of questions) { - await GroupToGroupJoinQuestion.forge({ group_id: this.id, question_id: q.id }).save() + await bookshelf.transaction(async transacting => { + if (changes.group_to_group_join_questions) { + const questions = await Promise.map(changes.group_to_group_join_questions.filter(jq => trim(jq.text) !== ''), async (jq) => { + return (await Question.where({ text: trim(jq.text) }).fetch({ transacting })) || (await Question.forge({ text: trim(jq.text) }).save({}, { transacting })) + }) + await GroupToGroupJoinQuestion.where({ group_id: this.id }).destroy({ require: false, transacting }) + for (let q of questions) { + await GroupToGroupJoinQuestion.forge({ group_id: this.id, question_id: q.id }).save({}, { transacting }) + } } - } - if (changes.join_questions) { - const questions = await Promise.map(changes.join_questions.filter(jq => trim(jq.text) !== ''), async (jq) => { - return (await Question.where({ text: trim(jq.text) }).fetch()) || (await Question.forge({ text: trim(jq.text) }).save()) - }) - await GroupJoinQuestion.where({ group_id: this.id }).destroy({ require: false }) - for (let q of questions) { - await GroupJoinQuestion.forge({ group_id: this.id, question_id: q.id }).save() + if (changes.join_questions) { + const questions = await Promise.map(changes.join_questions.filter(jq => trim(jq.text) !== ''), async (jq) => { + return (await Question.where({ text: trim(jq.text) }).fetch({ transacting })) || (await Question.forge({ text: trim(jq.text) }).save({}, { transacting })) + }) + await GroupJoinQuestion.where({ group_id: this.id }).destroy({ require: false, transacting }) + for (let q of questions) { + await GroupJoinQuestion.forge({ group_id: this.id, question_id: q.id }).save({}, { transacting }) + } + } + + if (changes.prerequisite_group_ids) { + // Go through all parent groups and reset which ones are prerequisites + const parentRelationships = await this.parentGroupRelationships().fetch({ transacting }) + await Promise.map(parentRelationships.models, async (relationship) => { + const isNowPrereq = changes.prerequisite_group_ids.includes(relationship.get('parent_group_id')) + if (relationship.getSetting('isPrerequisite') !== isNowPrereq) { + await relationship.addSetting({ isPrerequisite: isNowPrereq }, true, transacting) + } + }) } - } - if (changes.prerequisite_group_ids) { - // Go through all parent groups and reset which ones are prerequisites - const parentRelationships = await this.parentGroupRelationships().fetch() - await Promise.map(parentRelationships.models, async (relationship) => { - const isNowPrereq = changes.prerequisite_group_ids.includes(relationship.get('parent_group_id')) - if (relationship.getSetting('isPrerequisite') !== isNowPrereq) { - await relationship.addSetting({ isPrerequisite: isNowPrereq }, true) + if (changes.group_extensions) { + for (const extData of changes.group_extensions) { + const ext = await Extension.find(extData.type) + if (ext) { + const ge = (await GroupExtension.find(this.id, ext.id)) || new GroupExtension({ group_id: this.id, extension_id: ext.id }) + ge.set({ data: extData.data }) + await ge.save({}, { transacting }) + } else { + throw Error('Invalid extension type ' + extData.type) + } } - }) - } + } - if (changes.group_extensions) { - for (const extData of changes.group_extensions) { - const ext = await Extension.find(extData.type) - if (ext) { - const ge = (await GroupExtension.find(this.id, ext.id)) || new GroupExtension({ group_id: this.id, extension_id: ext.id }) - ge.set({ data: extData.data }) - await ge.save() - } else { - throw Error('Invalid extension type ' + extData.type) + if (changes.custom_views) { + const newViewIndex = 0 + const oldViewIndex = 0 + const currentViews = await this.customViews().fetch({ transacting }) + let currentView = currentViews.shift() + // TODO: more validation? + const newViews = changes.custom_views.filter(cv => trim(cv.name) !== '') + let newView = newViews.shift() + // Update current views, add new ones, delete old ones and try to be efficient about it + while (currentView || newView) { + if (newView) { + const topics = newView && newView.topics + delete newView.topics + delete newView.id + if (currentView) { + await currentView.save(newView, { transacting }) + } else { + currentView = await CustomView.forge({ ...newView, group_id: this.id }).save({}, { transacting }) + } + + await currentView.updateTopics(topics, transacting) + } else if (currentView) { + await currentView.destroy({ transacting }) + } else { + break + } + currentView = currentViews.shift() + newView = newViews.shift() } } - } - this.set(saneAttrs) - await this.validate().then(() => this.save()) + await this.save({}, { transacting }) + }) + + // If a new location is being passed in but not a new location_id then we geocode on the server + if (changes.location && changes.location !== this.get('location') && !changes.location_id) { + await Queue.classMethod('Group', 'geocodeLocation', { groupId: this.id }) + } return this }, diff --git a/api/models/mixins/HasSettings.js b/api/models/mixins/HasSettings.js index 8617a555f..49ac44372 100644 --- a/api/models/mixins/HasSettings.js +++ b/api/models/mixins/HasSettings.js @@ -1,10 +1,12 @@ import { get, has, isUndefined, merge, unset } from 'lodash' export default { - addSetting: function (value, save = false) { + addSetting: function (value, save = false, transacting = false) { this.set('settings', merge({}, this.get('settings'), value)) if (save) { - return this.save({settings: this.get('settings')}, {patch: true}) + const options = { patch: true } + if (transacting) settings['transacting'] = transacting + return this.save({settings: this.get('settings')}, options) } return this }, diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 604ae0240..1e72e3c83 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -10,9 +10,11 @@ export default function setupPostAttrs (userId, params) { updated_at: new Date(), announcement: params.announcement, accept_contributions: params.acceptContributions, + donations_link: params.donationsLink, + project_management_link: params.projectManagementLink, start_time: params.startTime ? new Date(Number(params.startTime)) : null, end_time: params.endTime ? new Date(Number(params.endTime)) : null, - is_public: params.isPublic + is_public: params.isPublic }, pick(params, 'name', 'description', 'type', 'starts_at', 'ends_at', 'location_id', 'location', 'created_from')) return Promise.resolve(attrs) diff --git a/api/services/Search/util.js b/api/services/Search/util.js index 09bd11430..f8ea3f555 100644 --- a/api/services/Search/util.js +++ b/api/services/Search/util.js @@ -4,6 +4,7 @@ import addTermToQueryBuilder from './addTermToQueryBuilder' export const filterAndSortPosts = curry((opts, q) => { const { + activePostsOnly = false, afterTime, beforeTime, boundingBox, @@ -51,6 +52,14 @@ export const filterAndSortPosts = curry((opts, q) => { }) } + if (activePostsOnly) { + q.whereNull('posts.fulfilled_at') + .andWhere(q2 => { + q2.whereNull('posts.end_time') + .orWhere('posts.end_time', '>=', moment().toDate()) + }) + } + if (afterTime) { q.where(q2 => q2.where('posts.start_time', '>=', afterTime) diff --git a/migrations/20211118103939_add_deleted_user_record.js b/migrations/20211118103939_add_deleted_user_record.js index fa0859116..68380ed15 100644 --- a/migrations/20211118103939_add_deleted_user_record.js +++ b/migrations/20211118103939_add_deleted_user_record.js @@ -1,5 +1,5 @@ -exports.up = function (knex) { +exports.up = function (knex) { // this is no longer used return knex.raw(` INSERT INTO users(email, first_name, last_name, active, email_validated, bio) VALUES ('deleted@hylo.com', 'Deleted', 'User', false, true, 'This is the generic "deleted user" account'); diff --git a/migrations/20220623155959_custom_view_for_groups.js b/migrations/20220623155959_custom_view_for_groups.js new file mode 100644 index 000000000..c44ce3ce9 --- /dev/null +++ b/migrations/20220623155959_custom_view_for_groups.js @@ -0,0 +1,29 @@ + +exports.up = async function (knex) { + await knex.schema.createTable('custom_views', table => { + table.increments().primary() + table.bigInteger('group_id').references('id').inTable('groups') + table.boolean('is_active').defaultTo(true) + table.string('search_text') + table.string('icon') + table.string('name') + table.string('external_link') + table.string('view_mode') + table.boolean('active_posts_only') + table.specificType('post_types', 'character varying(255)[]') + table.timestamp('created_at') + table.timestamp('updated_at') + table.integer('order').notNullable() + }) + + await knex.schema.createTable('custom_view_topics', table => { + table.increments().primary() + table.bigInteger('custom_view_id').references('id').inTable('custom_views').notNullable() + table.bigInteger('tag_id').references('id').inTable('tags').notNullable() + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTable('custom_views') + return knex.schema.dropTable('custom_view_topics') +} diff --git a/migrations/20220720131223_add_extra_project_fields_to_posts.js b/migrations/20220720131223_add_extra_project_fields_to_posts.js new file mode 100644 index 000000000..c5043a130 --- /dev/null +++ b/migrations/20220720131223_add_extra_project_fields_to_posts.js @@ -0,0 +1,14 @@ + +exports.up = function (knex) { + return knex.schema.table('posts', table => { + table.string('donations_link') + table.string('project_management_link') + }) +} + +exports.down = function (knex) { + return knex.schema.table('posts', table => { + table.dropColumn('project_management_link') + table.dropColumn('donations_link') + }) +} diff --git a/migrations/schema.sql b/migrations/schema.sql index d455da067..7c5b01ecf 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1515,7 +1515,9 @@ CREATE TABLE public.posts ( end_time timestamp with time zone, accept_contributions boolean DEFAULT false, location_id bigint, - is_public boolean DEFAULT false + is_public boolean DEFAULT false, + donations_link text, + project_management_link text ); diff --git a/package.json b/package.json index 5f3df1c84..62777c9a3 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,8 @@ "BlockedUser", "Comment", "CommentTag", + "CustomView", + "CustomViewTopic", "Contribution", "Device", "Email",