diff --git a/backend/functions/db/migrations/20210523145850_add_personalbest_flagged.ts b/backend/functions/db/migrations/20210523145850_add_personalbest_flagged.ts new file mode 100644 index 0000000..629c6c8 --- /dev/null +++ b/backend/functions/db/migrations/20210523145850_add_personalbest_flagged.ts @@ -0,0 +1,13 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable("personalBest", function (t) { + t.boolean("is_flagged").notNullable().defaultTo(false); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable("personalBest", function (t) { + t.dropColumn("is_flagged"); + }); +} diff --git a/backend/functions/migration.ts b/backend/functions/migration.ts index a265d6e..643298b 100644 --- a/backend/functions/migration.ts +++ b/backend/functions/migration.ts @@ -71,6 +71,7 @@ export async function up(knex: Knex): Promise { table.integer("time_elapsed").nullable(); table.decimal("moves_count").nullable(); table.boolean("is_current").notNullable(); + table.boolean("is_flagged").notNullable().defaultTo(false); table.text("public_comments").nullable(); table.dateTime("created_at").notNullable().defaultTo(knex.fn.now()); table.dateTime("updated_at").nullable(); diff --git a/backend/functions/schema.ts b/backend/functions/schema.ts index a4e4795..25c5cba 100644 --- a/backend/functions/schema.ts +++ b/backend/functions/schema.ts @@ -88,7 +88,8 @@ export type FilterByField = { /**Enum stored as a separate key value*/ userRole: | "NORMAL" | "NONE" - | "ADMIN"; + | "ADMIN" + | "MODERATOR"; /**Enum stored as is*/ userPermission: | "A_A" | "user_x" @@ -98,6 +99,7 @@ export type FilterByField = { | "user_create" | "user_delete" | "personalBest_create" + | "personalBest_flag" | "product_create" | "apiKey_create" | "userUserFollowLink_get"; @@ -307,6 +309,7 @@ export type FilterByField = { "personalBestFilterByField/createdBy.userUserFollowLink/user.id": FilterByField< Scalars["id"] >; + "personalBestFilterByField/isFlagged": FilterByField; personalBestFilterByObject: { id?: InputTypes["personalBestFilterByField/id"]; "createdBy.id"?: InputTypes["personalBestFilterByField/createdBy.id"]; @@ -318,6 +321,7 @@ export type FilterByField = { isCurrent?: InputTypes["personalBestFilterByField/isCurrent"]; setSize?: InputTypes["personalBestFilterByField/setSize"]; "createdBy.userUserFollowLink/user.id"?: InputTypes["personalBestFilterByField/createdBy.userUserFollowLink/user.id"]; + isFlagged?: InputTypes["personalBestFilterByField/isFlagged"]; }; personalBestPaginator: { first?: Scalars["number"]; @@ -343,21 +347,15 @@ export type FilterByField = { publicComments?: Scalars["string"] | null; }; updatePersonalBestFields: { - pbClass?: InputTypes["personalBestClass"]; - event?: InputTypes["event"]; - setSize?: Scalars["number"]; - attemptsSucceeded?: Scalars["number"] | null; - attemptsTotal?: Scalars["number"] | null; product?: InputTypes["product"] | null; - happenedOn?: Scalars["unixTimestamp"]; - timeElapsed?: Scalars["number"] | null; - movesCount?: Scalars["number"] | null; + isFlagged?: Scalars["boolean"]; publicComments?: Scalars["string"] | null; }; updatePersonalBest: { item: InputTypes["personalBest"]; fields: InputTypes["updatePersonalBestFields"]; }; + flagPersonalBest: { item: InputTypes["personalBest"] }; apiKey: { id?: Scalars["id"] }; "apiKeyFilterByField/id": FilterByField; "apiKeyFilterByField/user.id": FilterByField; @@ -710,6 +708,7 @@ export type UserUserFollowLinkEdge = Edge; Args: undefined; }; isCurrent: { Type: Scalars["boolean"]; Args: undefined }; + isFlagged: { Type: Scalars["boolean"]; Args: undefined }; publicComments: { Type: Scalars["string"] | null; Args: undefined }; /**The numerical score rank of this PB given its event, pbClass, and setSize, among public PBs only*/ ranking: { Type: Scalars["number"] | null; @@ -808,6 +807,10 @@ export type UserUserFollowLinkEdge = Edge; Type: PersonalBest; Args: InputTypes["updatePersonalBest"]; }; + flagPersonalBest: { + Type: PersonalBest; + Args: InputTypes["flagPersonalBest"]; + }; getApiKey: { Type: ApiKey; Args: InputTypes["apiKey"] }; getApiKeyPaginator: { Type: ApiKeyPaginator; diff --git a/backend/functions/src/schema/enums/userPermission.ts b/backend/functions/src/schema/enums/userPermission.ts index 59ec5bc..73f9754 100644 --- a/backend/functions/src/schema/enums/userPermission.ts +++ b/backend/functions/src/schema/enums/userPermission.ts @@ -14,6 +14,10 @@ export class userPermissionEnum extends Enum { "personalBest_create" ); + static readonly personalBest_flag = new userPermissionEnum( + "personalBest_flag" + ); + static readonly product_create = new userPermissionEnum("product_create"); static readonly apiKey_create = new userPermissionEnum("apiKey_create"); diff --git a/backend/functions/src/schema/enums/userRole.ts b/backend/functions/src/schema/enums/userRole.ts index 9ac1ae9..b81dbbf 100644 --- a/backend/functions/src/schema/enums/userRole.ts +++ b/backend/functions/src/schema/enums/userRole.ts @@ -4,4 +4,5 @@ export class userRoleKenum extends Kenum { static readonly NORMAL = new userRoleKenum("NORMAL", 1); static readonly NONE = new userRoleKenum("NONE", 2); static readonly ADMIN = new userRoleKenum("ADMIN", 3); + static readonly MODERATOR = new userRoleKenum("MODERATOR", 4); } diff --git a/backend/functions/src/schema/helpers/permissions.ts b/backend/functions/src/schema/helpers/permissions.ts index 5cb22b2..0a27ddc 100644 --- a/backend/functions/src/schema/helpers/permissions.ts +++ b/backend/functions/src/schema/helpers/permissions.ts @@ -2,6 +2,12 @@ import { userRoleKenum, userPermissionEnum } from "../enums"; export const userRoleToPermissionsMap = { [userRoleKenum.ADMIN.name]: [userPermissionEnum.A_A], + [userRoleKenum.MODERATOR.name]: [ + userPermissionEnum.personalBest_create, + userPermissionEnum.personalBest_flag, + userPermissionEnum.product_create, + userPermissionEnum.userUserFollowLink_get, + ], [userRoleKenum.NORMAL.name]: [ userPermissionEnum.personalBest_create, userPermissionEnum.product_create, diff --git a/backend/functions/src/schema/models/personalBest/rootResolver.ts b/backend/functions/src/schema/models/personalBest/rootResolver.ts index 0a4cd7d..2ec9d07 100644 --- a/backend/functions/src/schema/models/personalBest/rootResolver.ts +++ b/backend/functions/src/schema/models/personalBest/rootResolver.ts @@ -1,5 +1,11 @@ import { PersonalBest } from "../../services"; import { generateBaseRootResolvers } from "../../core/helpers/rootResolver"; +import { + GiraffeqlInputFieldType, + GiraffeqlInputType, + GiraffeqlInputTypeLookup, + GiraffeqlRootResolverType, +} from "giraffeql"; export default { ...generateBaseRootResolvers(PersonalBest, [ @@ -9,4 +15,27 @@ export default { "create", "update", ]), + flagPersonalBest: new GiraffeqlRootResolverType({ + name: "flagPersonalBest", + restOptions: { + method: "post", + route: "/flagPersonalBest", + }, + type: PersonalBest.typeDef, + allowNull: false, + args: new GiraffeqlInputFieldType({ + required: true, + type: new GiraffeqlInputType({ + name: "flagPersonalBest", + fields: { + item: new GiraffeqlInputFieldType({ + type: PersonalBest.inputTypeDefLookup, + required: true, + }), + }, + }), + }), + resolver: ({ req, args, query, fieldPath }) => + PersonalBest.flagRecord({ req, args, query, fieldPath }), + }), }; diff --git a/backend/functions/src/schema/models/personalBest/service.ts b/backend/functions/src/schema/models/personalBest/service.ts index 68bd4a8..d8a6a27 100644 --- a/backend/functions/src/schema/models/personalBest/service.ts +++ b/backend/functions/src/schema/models/personalBest/service.ts @@ -20,6 +20,7 @@ export class PersonalBestService extends PaginatedService { isCurrent: {}, setSize: {}, "createdBy.userUserFollowLink/user.id": {}, + isFlagged: {}, }; uniqueKeyMap = { @@ -489,6 +490,50 @@ export class PersonalBestService extends PaginatedService { }); } + @permissionsCheck("flag") + async flagRecord({ + req, + fieldPath, + args, + query, + data = {}, + isAdmin = false, + }: ServiceFunctionInputs) { + // args should be validated already + const validatedArgs = args; + + const item = await this.lookupRecord( + [{ field: "id" }], + validatedArgs.item, + fieldPath + ); + + // convert any lookup/joined fields into IDs + await this.handleLookupArgs(validatedArgs.fields, fieldPath); + + await Resolver.updateObjectType({ + typename: this.typename, + id: item.id, + updateFields: { + isFlagged: true, + updatedAt: 1, + }, + req, + fieldPath, + }); + + const returnData = await this.getRecord({ + req, + args: { id: item.id }, + query, + fieldPath, + isAdmin, + data, + }); + + return returnData; + } + @permissionsCheck("delete") async deleteRecord({ req, diff --git a/backend/functions/src/schema/models/personalBest/typeDef.ts b/backend/functions/src/schema/models/personalBest/typeDef.ts index 9dae9d9..b09680a 100644 --- a/backend/functions/src/schema/models/personalBest/typeDef.ts +++ b/backend/functions/src/schema/models/personalBest/typeDef.ts @@ -35,16 +35,19 @@ export default new GiraffeqlObjectType({ sqlOptions: { field: "pb_class", }, + typeDefOptions: { addable: true, updateable: false }, }), event: generateJoinableField({ service: Event, allowNull: false, + typeDefOptions: { addable: true, updateable: false }, }), setSize: generateIntegerField({ allowNull: false, sqlOptions: { field: "set_size", }, + typeDefOptions: { addable: true, updateable: false }, }), score: generateIntegerField({ allowNull: false, @@ -54,11 +57,13 @@ export default new GiraffeqlObjectType({ allowNull: true, description: "The number of successful attempts", sqlOptions: { field: "attempts_succeeded" }, + typeDefOptions: { addable: true, updateable: false }, }), attemptsTotal: generateIntegerField({ allowNull: true, description: "The total number of attempts", sqlOptions: { field: "attempts_total" }, + typeDefOptions: { addable: true, updateable: false }, }), product: generateJoinableField({ service: Product, @@ -68,22 +73,31 @@ export default new GiraffeqlObjectType({ allowNull: false, defaultValue: knex.fn.now(), // not really setting via DB. default is calculated manually in the createRecord function sqlOptions: { field: "happened_on" }, + typeDefOptions: { addable: true, updateable: false }, }), timeElapsed: generateIntegerField({ allowNull: true, description: "The amount of ms time elapsed for the pb attempt", sqlOptions: { field: "time_elapsed" }, + typeDefOptions: { addable: true, updateable: false }, }), movesCount: generateDecimalField({ allowNull: true, description: "The amount of moves used in the pb attempt", sqlOptions: { field: "moves_count" }, + typeDefOptions: { addable: true, updateable: false }, }), isCurrent: generateBooleanField({ allowNull: false, typeDefOptions: { addable: false, updateable: false }, sqlOptions: { field: "is_current" }, }), + isFlagged: generateBooleanField({ + allowNull: false, + defaultValue: false, + typeDefOptions: { addable: false, updateable: true }, + sqlOptions: { field: "is_flagged" }, + }), publicComments: generateTextField({ allowNull: true, sqlOptions: { @@ -101,11 +115,16 @@ export default new GiraffeqlObjectType({ "pbClass.id", "setSize", "isCurrent", + "isFlagged", "createdBy.isPublic", ], async resolver({ parentValue, fieldPath }) { - // if not a current PB or user is not public, return null - if (!parentValue.isCurrent || !parentValue.createdBy.isPublic) + // if not a current PB or user is not public or isFlagged, return null + if ( + !parentValue.isCurrent || + !parentValue.createdBy.isPublic || + parentValue.isFlagged + ) return null; const resultsCount = await Resolver.countObjectType( @@ -143,6 +162,11 @@ export default new GiraffeqlObjectType({ operator: "eq", value: true, }, + { + field: "isFlagged", + operator: "eq", + value: false, + }, ], }, true diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index f37a4f4..aa905fc 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -280,6 +280,11 @@ export default { operator: 'eq', value: 1, }, + { + field: 'isFlagged', + operator: 'eq', + value: false, + }, ], }), loginRequired: false, diff --git a/frontend/models/actions/index.ts b/frontend/models/actions/index.ts new file mode 100644 index 0000000..d4adb2a --- /dev/null +++ b/frontend/models/actions/index.ts @@ -0,0 +1,38 @@ +import { executeGiraffeql } from '~/services/giraffeql' +import { handleError } from '~/services/base' + +export const flagPersonalBest = async (that, item) => { + try { + if (!that.$store.getters['auth/user']) { + throw new Error('Login required') + } + + // check for moderator + if ( + !['ADMIN', 'MODERATOR'].includes(that.$store.getters['auth/user'].role) + ) { + throw new Error('Must be moderator to flag PBs') + } + + await executeGiraffeql(that, { + flagPersonalBest: { + __args: { + item: { + id: item.id, + }, + }, + }, + }) + + that.$notifier.showSnackbar({ + message: `Flagged Personal Best`, + variant: 'success', + }) + + that.reset({ + resetExpanded: false, + }) + } catch (err) { + handleError(that, err) + } +} diff --git a/frontend/models/personalBest.ts b/frontend/models/personalBest.ts index 3da734f..ec427d2 100644 --- a/frontend/models/personalBest.ts +++ b/frontend/models/personalBest.ts @@ -1,4 +1,5 @@ import { getEvents, getPersonalBestClasses } from '../services/dropdown' +import { flagPersonalBest } from './actions' import type { RecordInfo } from '~/types' import TimeagoColumn from '~/components/table/common/timeagoColumn.vue' import UserColumn from '~/components/table/common/userColumn.vue' @@ -237,6 +238,11 @@ export const PersonalBest = >{ inputType: 'switch', parseQueryValue: (val) => val === 'true', }, + isFlagged: { + text: 'Is Flagged', + inputType: 'switch', + parseQueryValue: (val) => val === 'true', + }, 'createdBy.userUserFollowLink/user.id': {}, publicComments: { text: 'Public Comments', @@ -290,6 +296,10 @@ export const PersonalBest = >{ field: 'isCurrent', operator: 'eq', }, + { + field: 'isFlagged', + operator: 'eq', + }, ], headers: [ { @@ -349,9 +359,7 @@ export const PersonalBest = >{ component: EditPersonalBestInterface, }, editOptions: { - fields: ['publicComments', 'product.id'], - icon: 'mdi-comment-edit', - text: 'Comments', + fields: ['publicComments', 'product.id', 'isFlagged'], }, viewOptions: { fields: [ @@ -363,6 +371,7 @@ export const PersonalBest = >{ 'publicComments', 'product.name', 'isCurrent', + 'isFlagged', 'createdBy.name+createdBy.avatar+createdBy.id', ], component: ViewRecordTableInterface, @@ -377,4 +386,12 @@ export const PersonalBest = >{ }, expandTypes: [], + + customActions: [ + { + text: 'Flag PB', + icon: 'mdi-flag', + handleClick: flagPersonalBest, + }, + ], } diff --git a/frontend/models/special/myPbs.ts b/frontend/models/special/myPbs.ts index cc30ca8..670e575 100644 --- a/frontend/models/special/myPbs.ts +++ b/frontend/models/special/myPbs.ts @@ -6,6 +6,11 @@ const MyPbs = { paginationOptions: { ...(!!PersonalBest.paginationOptions && PersonalBest.paginationOptions), }, + editOptions: { + fields: ['publicComments', 'product.id'], + icon: 'mdi-comment-edit', + text: 'Comments', + }, enterOptions: {}, } diff --git a/frontend/types/schema.ts b/frontend/types/schema.ts index 5dfee4a..be85009 100644 --- a/frontend/types/schema.ts +++ b/frontend/types/schema.ts @@ -85,7 +85,11 @@ export type FilterByField = { /**Regex Field*/ regex: RegExp /**Valid JSON*/ json: unknown /**Valid JSON as a string*/ jsonString: string - /**Enum stored as a separate key value*/ userRole: 'NORMAL' | 'NONE' | 'ADMIN' + /**Enum stored as a separate key value*/ userRole: + | 'NORMAL' + | 'NONE' + | 'ADMIN' + | 'MODERATOR' /**Enum stored as is*/ userPermission: | 'A_A' | 'user_x' @@ -95,6 +99,7 @@ export type FilterByField = { | 'user_create' | 'user_delete' | 'personalBest_create' + | 'personalBest_flag' | 'product_create' | 'apiKey_create' | 'userUserFollowLink_get' @@ -302,6 +307,7 @@ export type FilterByField = { 'personalBestFilterByField/createdBy.userUserFollowLink/user.id': FilterByField< Scalars['id'] > + 'personalBestFilterByField/isFlagged': FilterByField personalBestFilterByObject: { id?: InputTypes['personalBestFilterByField/id'] 'createdBy.id'?: InputTypes['personalBestFilterByField/createdBy.id'] @@ -313,6 +319,7 @@ export type FilterByField = { isCurrent?: InputTypes['personalBestFilterByField/isCurrent'] setSize?: InputTypes['personalBestFilterByField/setSize'] 'createdBy.userUserFollowLink/user.id'?: InputTypes['personalBestFilterByField/createdBy.userUserFollowLink/user.id'] + isFlagged?: InputTypes['personalBestFilterByField/isFlagged'] } personalBestPaginator: { first?: Scalars['number'] @@ -338,21 +345,15 @@ export type FilterByField = { publicComments?: Scalars['string'] | null } updatePersonalBestFields: { - pbClass?: InputTypes['personalBestClass'] - event?: InputTypes['event'] - setSize?: Scalars['number'] - attemptsSucceeded?: Scalars['number'] | null - attemptsTotal?: Scalars['number'] | null product?: InputTypes['product'] | null - happenedOn?: Scalars['unixTimestamp'] - timeElapsed?: Scalars['number'] | null - movesCount?: Scalars['number'] | null + isFlagged?: Scalars['boolean'] publicComments?: Scalars['string'] | null } updatePersonalBest: { item: InputTypes['personalBest'] fields: InputTypes['updatePersonalBestFields'] } + flagPersonalBest: { item: InputTypes['personalBest'] } apiKey: { id?: Scalars['id'] } 'apiKeyFilterByField/id': FilterByField 'apiKeyFilterByField/user.id': FilterByField @@ -705,6 +706,7 @@ export type UserUserFollowLinkEdge = Edge Args: undefined } isCurrent: { Type: Scalars['boolean']; Args: undefined } + isFlagged: { Type: Scalars['boolean']; Args: undefined } publicComments: { Type: Scalars['string'] | null; Args: undefined } /**The numerical score rank of this PB given its event, pbClass, and setSize, among public PBs only*/ ranking: { Type: Scalars['number'] | null @@ -803,6 +805,10 @@ export type UserUserFollowLinkEdge = Edge Type: PersonalBest Args: InputTypes['updatePersonalBest'] } + flagPersonalBest: { + Type: PersonalBest + Args: InputTypes['flagPersonalBest'] + } getApiKey: { Type: ApiKey; Args: InputTypes['apiKey'] } getApiKeyPaginator: { Type: ApiKeyPaginator