diff --git a/backend/config/custom-environment-variables.json b/backend/config/custom-environment-variables.json index b91e732d17..5abba2397e 100644 --- a/backend/config/custom-environment-variables.json +++ b/backend/config/custom-environment-variables.json @@ -125,6 +125,11 @@ "webhookSecret": "CROWD_GITHUB_WEBHOOK_SECRET", "isCommitDataEnabled": "CROWD_GITHUB_IS_COMMIT_DATA_ENABLED" }, + "githubIssueReporter": { + "appId": "CROWD_GITHUB_ISSUE_REPORTER_APP_ID", + "privateKey": "CROWD_GITHUB_ISSUE_REPORTER_PRIVATE_KEY", + "installationId": "CROWD_GITHUB_ISSUE_REPORTER_INSTALLATION_ID" + }, "stackexchange": { "key": "CROWD_STACKEXCHANGE_KEY" }, diff --git a/backend/package.json b/backend/package.json index da5543ddad..e1b3a79781 100644 --- a/backend/package.json +++ b/backend/package.json @@ -67,6 +67,7 @@ "@crowd/types": "workspace:*", "@google-cloud/storage": "5.3.0", "@octokit/auth-app": "^3.6.1", + "@octokit/core": "^6.1.2", "@octokit/graphql": "^4.8.0", "@octokit/request": "^5.6.3", "@opensearch-project/opensearch": "^2.11.0", diff --git a/backend/src/api/member/index.ts b/backend/src/api/member/index.ts index 5138820ca8..10848e85ef 100644 --- a/backend/src/api/member/index.ts +++ b/backend/src/api/member/index.ts @@ -48,4 +48,9 @@ export default (app) => { require('./organization').default(app) require('./attributes').default(app) require('./affiliation').default(app) + + app.post( + `/tenant/:tenantId/member/:id/data-issue`, + safeWrap(require('./memberDataIssueCreate').default), + ) } diff --git a/backend/src/api/member/memberDataIssueCreate.ts b/backend/src/api/member/memberDataIssueCreate.ts new file mode 100644 index 0000000000..70e5872c90 --- /dev/null +++ b/backend/src/api/member/memberDataIssueCreate.ts @@ -0,0 +1,16 @@ +import { DataIssueEntity } from '@crowd/types' + +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import DataIssueService from '@/services/dataIssueService' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.dataIssueCreate) + + const payload = await new DataIssueService(req).createDataIssue( + { ...req.body, entity: DataIssueEntity.PERSON }, + req.params.id, + ) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/organization/index.ts b/backend/src/api/organization/index.ts index 031d96bc30..96c5ba9b00 100644 --- a/backend/src/api/organization/index.ts +++ b/backend/src/api/organization/index.ts @@ -51,4 +51,9 @@ export default (app) => { // list organizations across all segments app.post(`/tenant/:tenantId/organization/list`, safeWrap(require('./organizationList').default)) + + app.post( + `/tenant/:tenantId/organization/:id/data-issue`, + safeWrap(require('./organizationDataIssueCreate').default), + ) } diff --git a/backend/src/api/organization/organizationDataIssueCreate.ts b/backend/src/api/organization/organizationDataIssueCreate.ts new file mode 100644 index 0000000000..695d642e4f --- /dev/null +++ b/backend/src/api/organization/organizationDataIssueCreate.ts @@ -0,0 +1,16 @@ +import { DataIssueEntity } from '@crowd/types' + +import PermissionChecker from '../../services/user/permissionChecker' +import Permissions from '../../security/permissions' +import DataIssueService from '@/services/dataIssueService' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.dataIssueCreate) + + const payload = await new DataIssueService(req).createDataIssue( + { ...req.body, entity: DataIssueEntity.ORGANIZATION }, + req.params.id, + ) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/conf/index.ts b/backend/src/conf/index.ts index af517db7a6..c00a18061d 100644 --- a/backend/src/conf/index.ts +++ b/backend/src/conf/index.ts @@ -1,6 +1,7 @@ import config from 'config' import { IRedisConfiguration } from '@crowd/redis' import { ISearchSyncApiConfig } from '@crowd/opensearch' +import { IGithubIssueReporterConfiguration } from '@crowd/types' import { IDatabaseConfig } from '@crowd/data-access-layer/src/database' import { IQueueClientConfig } from '@crowd/queue' import { @@ -113,6 +114,9 @@ export const DISCORD_CONFIG: DiscordConfiguration = config.get('github') +export const GITHUB_ISSUE_REPORTER_CONFIG: IGithubIssueReporterConfiguration = + config.get('githubIssueReporter') + export const SENDGRID_CONFIG: SendgridConfiguration = config.get('sendgrid') export const NETLIFY_CONFIG: NetlifyConfiguration = config.get('netlify') diff --git a/backend/src/database/migrations/U1727097932__createDataIssuesTable.sql b/backend/src/database/migrations/U1727097932__createDataIssuesTable.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/database/migrations/V1727097932__createDataIssuesTable.sql b/backend/src/database/migrations/V1727097932__createDataIssuesTable.sql new file mode 100644 index 0000000000..4e4e66f941 --- /dev/null +++ b/backend/src/database/migrations/V1727097932__createDataIssuesTable.sql @@ -0,0 +1,17 @@ +create table public."dataIssues" ( + "id" uuid, + entity text not null, + "profileUrl" text not null, + "dataIssue" text not null, + "dataType" text not null, + "githubIssueUrl" text not null, + "description" text not null, + "createdById" uuid not null, + "createdAt" timestamp with time zone default now() not null, + "updatedAt" timestamp with time zone default now() not null, + "resolutionEmailSentAt" timestamp with time zone default null, + "resolutionEmailSentTo" text null, + primary key ("id"), + foreign key ("createdById") references users (id) on delete cascade, + unique ("githubIssueUrl") +); \ No newline at end of file diff --git a/backend/src/security/permissions.ts b/backend/src/security/permissions.ts index 69565c8c5a..2ae3741ca6 100644 --- a/backend/src/security/permissions.ts +++ b/backend/src/security/permissions.ts @@ -1068,6 +1068,17 @@ class Permissions { TenantPlans.Scale, ], }, + dataIssueCreate: { + id: 'dataIssueCreate', + allowedRoles: [roles.admin, roles.projectAdmin, roles.readonly], + allowedPlans: [ + TenantPlans.Essential, + TenantPlans.Growth, + TenantPlans.EagleEye, + TenantPlans.Enterprise, + TenantPlans.Scale, + ], + }, } } diff --git a/backend/src/services/dataIssueService.ts b/backend/src/services/dataIssueService.ts new file mode 100644 index 0000000000..8b6d4ccc97 --- /dev/null +++ b/backend/src/services/dataIssueService.ts @@ -0,0 +1,119 @@ +import { Octokit } from '@octokit/core' +import { request } from '@octokit/request' +import { createAppAuth } from '@octokit/auth-app' +import { LoggerBase } from '@crowd/logging' +import { PgPromiseQueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' +import { createDataIssue } from '@crowd/data-access-layer/src/data_issues' +import { findOrgById, OrganizationField } from '@crowd/data-access-layer/src/orgs' +import { findMemberById, MemberField } from '@crowd/data-access-layer/src/members' +import { DataIssueEntity } from '@crowd/types' +import { InstallationAccessTokenData } from '@octokit/auth-app/dist-types/types' +import { IServiceOptions } from './IServiceOptions' +import SequelizeRepository from '@/database/repositories/sequelizeRepository' +import { GITHUB_ISSUE_REPORTER_CONFIG } from '@/conf' + +export interface IDataIssueCreatePayload { + entity: DataIssueEntity + profileUrl: string + dataIssue: string + dataType: string + description: string + githubIssueUrl: string + createdById: string +} + +export default class DataIssueService extends LoggerBase { + private readonly qx: PgPromiseQueryExecutor + + private readonly DATA_ISSUES_GITHUB_REPO: string = 'linux-foundation-support' + + private readonly DATA_ISSUES_GITHUB_OWNER: string = 'CrowdDotDev' + + options: IServiceOptions + + constructor(options: IServiceOptions) { + super(options.log) + this.options = options + } + + public async createDataIssue(data: IDataIssueCreatePayload, entityId: string) { + const qx = SequelizeRepository.getQueryExecutor(this.options) + const user = SequelizeRepository.getCurrentUser(this.options) + + let entityName: string + let reportedBy: string + + if (data.entity === DataIssueEntity.ORGANIZATION) { + const organization = await findOrgById(qx, entityId, [ + OrganizationField.ID, + OrganizationField.DISPLAY_NAME, + ]) + entityName = organization.displayName + } else if (data.entity === DataIssueEntity.PERSON) { + const member = await findMemberById(qx, entityId, [MemberField.ID, MemberField.DISPLAY_NAME]) + entityName = member.displayName + } else { + throw new Error(`Unsupported data issue entity ${data.entity}!1`) + } + + if (user.fullName) { + reportedBy = `${user.fullName} - ${user.email}` + } else { + reportedBy = `${user.email}` + } + + const appToken = await DataIssueService.getGitHubAppToken( + GITHUB_ISSUE_REPORTER_CONFIG.appId, + GITHUB_ISSUE_REPORTER_CONFIG.privateKey, + GITHUB_ISSUE_REPORTER_CONFIG.installationId, + ) + + try { + const result = await request( + `POST /repos/${this.DATA_ISSUES_GITHUB_OWNER}/${this.DATA_ISSUES_GITHUB_REPO}/issues`, + { + headers: { + authorization: `token ${appToken}`, + }, + title: `[Data Issue] ${entityName} (${data.entity[0].toUpperCase()}${data.entity + .slice(1) + .toLowerCase()})`, + body: `**Entity**\n${entityName}\n\n**Profile**\n[${data.profileUrl}](${data.profileUrl})\n\n**Data Issue**\n${data.dataIssue}\n\n**Description**\n${data.description}\n\n**Reported by**\n${reportedBy}`, + labels: ['Data issue'], + }, + ) + const res = await createDataIssue(qx, { + ...data, + githubIssueUrl: result.data.html_url, + createdById: user.id, + }) + + return res + } catch (error) { + this.log.info(error) + throw new Error('Error during session create!') + } + } + + public static async getGitHubAppToken( + appId: string, + privateKey: string, + installationId: string, + ): Promise { + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId, + privateKey, + installationId, + }, + }) + + const authResponse = await octokit.auth({ + type: 'installation', + installationId, + }) + + return (authResponse as InstallationAccessTokenData).token + } +} diff --git a/frontend/config/styles/components/radio.scss b/frontend/config/styles/components/radio.scss index ee80dd3b9f..a22ebce916 100644 --- a/frontend/config/styles/components/radio.scss +++ b/frontend/config/styles/components/radio.scss @@ -22,7 +22,7 @@ --lf-radio-unchecked-background: var(--lf-color-white); // Checked - --lf-radio-checked-border: var(--lf-color-gray-900); + --lf-radio-checked-border: var(--lf-color-primary-500); --lf-radio-checked-background: var(--lf-color-white); // Disabled Unchecked diff --git a/frontend/src/app.vue b/frontend/src/app.vue index f2fd07f749..9ba37a2a11 100644 --- a/frontend/src/app.vue +++ b/frontend/src/app.vue @@ -14,6 +14,7 @@
+ @@ -27,11 +28,13 @@ import { useActivityTypeStore } from '@/modules/activity/store/type'; import { useAuthStore } from '@/modules/auth/store/auth.store'; import useSessionTracking from '@/shared/modules/monitoring/useSessionTracking'; import { useLfSegmentsStore } from '@/modules/lf/segments/store'; +import LfGlobals from '@/shared/components/globals.vue'; export default { name: 'App', components: { + LfGlobals, AppResizePage, }, diff --git a/frontend/src/modules/contributor/components/details/contributor-details-actions.vue b/frontend/src/modules/contributor/components/details/contributor-details-actions.vue index d8178887a9..65181fdb7b 100644 --- a/frontend/src/modules/contributor/components/details/contributor-details-actions.vue +++ b/frontend/src/modules/contributor/components/details/contributor-details-actions.vue @@ -2,6 +2,15 @@
+ + Report data issue + (); const { hasPermission } = usePermissions(); const { isMasked } = useContributorHelpers(); +const { setReportDataModal } = useSharedStore(); const isMergeSuggestionsDialogOpen = ref(false); const isMergeDialogOpen = ref(null); diff --git a/frontend/src/modules/contributor/components/details/identity/contributor-details-identity-item.vue b/frontend/src/modules/contributor/components/details/identity/contributor-details-identity-item.vue index 2f0b45be92..4287ea5ef5 100644 --- a/frontend/src/modules/contributor/components/details/identity/contributor-details-identity-item.vue +++ b/frontend/src/modules/contributor/components/details/identity/contributor-details-identity-item.vue @@ -59,7 +59,7 @@
- + + + Report issue + + - + Unmerge identity - - - - + - Delete identity - - + + + Delete identity + + + @@ -127,6 +146,8 @@ import usePermissions from '@/shared/modules/permissions/helpers/usePermissions' import { LfPermission } from '@/shared/modules/permissions/types/Permissions'; import { computed, ref } from 'vue'; import LfVerifiedIdentityBadge from '@/shared/modules/identities/components/verified-identity-badge.vue'; +import { ReportDataType } from '@/shared/modules/report-issue/constants/report-data-type.enum'; +import { useSharedStore } from '@/shared/pinia/shared.store'; const props = defineProps<{ identity: ContributorIdentity, @@ -136,6 +157,7 @@ const props = defineProps<{ const emit = defineEmits<{(e: 'edit'): void, (e: 'unmerge'): void }>(); const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); const { deleteContributorIdentity } = useContributorStore(); diff --git a/frontend/src/modules/contributor/components/details/overview/contributor-details-projects.vue b/frontend/src/modules/contributor/components/details/overview/contributor-details-projects.vue index 6cfda5ec31..fd837c4481 100644 --- a/frontend/src/modules/contributor/components/details/overview/contributor-details-projects.vue +++ b/frontend/src/modules/contributor/components/details/overview/contributor-details-projects.vue @@ -71,7 +71,7 @@
-
+
Add affiliation @@ -90,9 +90,19 @@ View activity - + Edit affiliation + + Report issue + @@ -132,6 +142,10 @@ import LfContributorDetailsProjectsMaintainer from '@/modules/contributor/components/details/overview/project/contributor-details-projects-maintainer.vue'; import LfContributorDetailsProjectsSorting from '@/modules/contributor/components/details/overview/project/contributor-details-projects-sorting.vue'; +import usePermissions from '@/shared/modules/permissions/helpers/usePermissions'; +import { LfPermission } from '@/shared/modules/permissions/types/Permissions'; +import { ReportDataType } from '@/shared/modules/report-issue/constants/report-data-type.enum'; +import { useSharedStore } from '@/shared/pinia/shared.store'; const props = defineProps<{ contributor: Contributor, @@ -140,6 +154,9 @@ const props = defineProps<{ const router = useRouter(); const route = useRoute(); +const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); + const showMore = ref(false); const sorting = ref('name_ASC'); const isAffilationEditOpen = ref(false); diff --git a/frontend/src/modules/contributor/components/details/work-history/contributor-details-work-history-item.vue b/frontend/src/modules/contributor/components/details/work-history/contributor-details-work-history-item.vue index afa541e134..6ed901abac 100644 --- a/frontend/src/modules/contributor/components/details/work-history/contributor-details-work-history-item.vue +++ b/frontend/src/modules/contributor/components/details/work-history/contributor-details-work-history-item.vue @@ -53,13 +53,25 @@ - + Edit work experience - - - Delete work experience + + Report issue +
@@ -84,6 +96,10 @@ import Message from '@/shared/message/message'; import ConfirmDialog from '@/shared/dialog/confirm-dialog'; import { EventType, FeatureEventKey } from '@/shared/modules/monitoring/types/event'; import useProductTracking from '@/shared/modules/monitoring/useProductTracking'; +import usePermissions from '@/shared/modules/permissions/helpers/usePermissions'; +import { LfPermission } from '@/shared/modules/permissions/types/Permissions'; +import { useSharedStore } from '@/shared/pinia/shared.store'; +import { ReportDataType } from '@/shared/modules/report-issue/constants/report-data-type.enum'; const props = defineProps<{ organization: Organization, @@ -96,6 +112,9 @@ const { selectedProjectGroup } = storeToRefs(useLfSegmentsStore()); const { deleteContributorOrganization } = useContributorStore(); const { trackEvent } = useProductTracking(); +const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); + const hovered = ref(false); const getDateRange = (dateStart?: string, dateEnd?: string) => { diff --git a/frontend/src/modules/contributor/store/contributor.actions.ts b/frontend/src/modules/contributor/store/contributor.actions.ts index ae51ecc842..1048526011 100644 --- a/frontend/src/modules/contributor/store/contributor.actions.ts +++ b/frontend/src/modules/contributor/store/contributor.actions.ts @@ -139,4 +139,9 @@ export default { return ContributorAttributesApiService.update(memberId, attributes) .then(this.setAttributes); }, + + /** Report Data Modal * */ + setReportDataModal(data: any) { + this.reportDataModal = data; + }, }; diff --git a/frontend/src/modules/contributor/store/contributor.state.ts b/frontend/src/modules/contributor/store/contributor.state.ts index 4e247c3af2..e2f91d5279 100644 --- a/frontend/src/modules/contributor/store/contributor.state.ts +++ b/frontend/src/modules/contributor/store/contributor.state.ts @@ -2,8 +2,10 @@ import { Contributor } from '@/modules/contributor/types/Contributor'; export interface ContributorState { contributor: Contributor | null; + reportDataModal: any | null; } export default () => ({ contributor: null, + reportDataModal: null, } as ContributorState); diff --git a/frontend/src/modules/layout/components/menu/menu.vue b/frontend/src/modules/layout/components/menu/menu.vue index 5ddf546fcd..b1a0920574 100644 --- a/frontend/src/modules/layout/components/menu/menu.vue +++ b/frontend/src/modules/layout/components/menu/menu.vue @@ -65,16 +65,19 @@ :is-collapsed="isCollapsed" :to="{ path: '/community-lens' }" /> -
-
-
- + > +
+
+
+ + diff --git a/frontend/src/modules/organization/components/details/domains/organization-details-domain-item.vue b/frontend/src/modules/organization/components/details/domains/organization-details-domain-item.vue index fcfc4e6c87..b6d445edd7 100644 --- a/frontend/src/modules/organization/components/details/domains/organization-details-domain-item.vue +++ b/frontend/src/modules/organization/components/details/domains/organization-details-domain-item.vue @@ -27,7 +27,7 @@
@@ -38,19 +38,36 @@ Edit domain + + Report issue + - + Unmerge domain - + (); const platformLabel = (platforms: string[]) => CrowdIntegrations.getPlatformsLabel(platforms); const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); const { updateOrganization } = useOrganizationStore(); const hovered = ref(false); diff --git a/frontend/src/modules/organization/components/details/email/organization-details-email-item.vue b/frontend/src/modules/organization/components/details/email/organization-details-email-item.vue index 65e6256ea1..2e97c4a122 100644 --- a/frontend/src/modules/organization/components/details/email/organization-details-email-item.vue +++ b/frontend/src/modules/organization/components/details/email/organization-details-email-item.vue @@ -39,7 +39,7 @@
- + Edit email - + + Report issue + - + Unmerge email - + (); const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); const { updateOrganization } = useOrganizationStore(); diff --git a/frontend/src/modules/organization/components/details/identity/organization-details-identity-item.vue b/frontend/src/modules/organization/components/details/identity/organization-details-identity-item.vue index f6e99af4bd..1e55a9b123 100644 --- a/frontend/src/modules/organization/components/details/identity/organization-details-identity-item.vue +++ b/frontend/src/modules/organization/components/details/identity/organization-details-identity-item.vue @@ -47,7 +47,7 @@
- + Edit identity + + Report issue + - + Unmerge identity - + (); const { hasPermission } = usePermissions(); +const { setReportDataModal } = useSharedStore(); const { updateOrganization } = useOrganizationStore(); diff --git a/frontend/src/modules/organization/components/details/organization-details-actions.vue b/frontend/src/modules/organization/components/details/organization-details-actions.vue index 6dbf2a0e71..bff514da2c 100644 --- a/frontend/src/modules/organization/components/details/organization-details-actions.vue +++ b/frontend/src/modules/organization/components/details/organization-details-actions.vue @@ -1,6 +1,15 @@