diff --git a/.github/workflows/lint-frontend.yml b/.github/workflows/lint-frontend.yml index c6669ecd0c..b4fe721381 100644 --- a/.github/workflows/lint-frontend.yml +++ b/.github/workflows/lint-frontend.yml @@ -13,8 +13,5 @@ jobs: uses: actions/checkout@v2 - name: Lint code - uses: reviewdog/action-eslint@v1 - with: - reporter: github-pr-review - fail_on_error: true - workdir: 'frontend/' \ No newline at end of file + run: npm i && npm run lint + working-directory: frontend diff --git a/backend/.env.dist.local b/backend/.env.dist.local index dbec134ba0..ad0575574e 100755 --- a/backend/.env.dist.local +++ b/backend/.env.dist.local @@ -133,4 +133,8 @@ CROWD_QDRANT_PORT=6333 # Enrichment settings CROWD_ENRICHMENT_URL= -CROWD_ENRICHMENT_API_KEY= \ No newline at end of file +CROWD_ENRICHMENT_API_KEY= + +# EagleEye settings +CROWD_EAGLE_EYE_URL= +CROWD_EAGLE_EYE_API_KEY= \ No newline at end of file diff --git a/backend/config/custom-environment-variables.json b/backend/config/custom-environment-variables.json index 56b8645816..6677c686c0 100644 --- a/backend/config/custom-environment-variables.json +++ b/backend/config/custom-environment-variables.json @@ -94,6 +94,7 @@ "templateWeeklyAnalytics": "CROWD_SENDGRID_TEMPLATE_WEEKLY_ANALYTICS", "templateIntegrationDone": "CROWD_SENDGRID_TEMPLATE_INTEGRATION_DONE", "templateCsvExport": "CROWD_SENDGRID_TEMPLATE_CSV_EXPORT", + "templateEagleEyeDigest": "CROWD_SENDGRID_TEMPLATE_EAGLE_EYE_DIGEST", "weeklyAnalyticsUnsubscribeGroupId": "CROWD_SENDGRID_WEEKLY_ANALYTICS_UNSUBSCRIBE_GROUP_ID" }, "plans": { @@ -135,5 +136,9 @@ "enrichment": { "url": "CROWD_ENRICHMENT_URL", "apiKey": "CROWD_ENRICHMENT_API_KEY" + }, + "eagleEye": { + "url": "CROWD_EAGLE_EYE_URL", + "apiKey": "CROWD_EAGLE_EYE_API_KEY" } } diff --git a/backend/config/default.json b/backend/config/default.json index c31da9bcbb..13dfcfeaed 100644 --- a/backend/config/default.json +++ b/backend/config/default.json @@ -34,5 +34,6 @@ "maxRetrospectInSeconds": 3600 }, "github": {}, - "enrichment": {} + "enrichment": {}, + "eagleEye": {} } diff --git a/backend/src/api/eagleEyeContent/eagleEyeActionCreate.ts b/backend/src/api/eagleEyeContent/eagleEyeActionCreate.ts new file mode 100644 index 0000000000..8d25a39212 --- /dev/null +++ b/backend/src/api/eagleEyeContent/eagleEyeActionCreate.ts @@ -0,0 +1,11 @@ +import Permissions from '../../security/permissions' +import EagleEyeActionService from '../../services/eagleEyeActionService' +import PermissionChecker from '../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeActionCreate) + + const payload = await new EagleEyeActionService(req).create(req.body, req.params.contentId) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/eagleEyeContent/eagleEyeActionDestroy.ts b/backend/src/api/eagleEyeContent/eagleEyeActionDestroy.ts new file mode 100644 index 0000000000..7684b74174 --- /dev/null +++ b/backend/src/api/eagleEyeContent/eagleEyeActionDestroy.ts @@ -0,0 +1,11 @@ +import Permissions from '../../security/permissions' +import EagleEyeActionService from '../../services/eagleEyeActionService' +import PermissionChecker from '../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeActionDestroy) + + const payload = await new EagleEyeActionService(req).destroy(req.params.actionId) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/eagleEyeContent/eagleEyeContentList.ts b/backend/src/api/eagleEyeContent/eagleEyeContentReply.ts similarity index 50% rename from backend/src/api/eagleEyeContent/eagleEyeContentList.ts rename to backend/src/api/eagleEyeContent/eagleEyeContentReply.ts index 40fd0fb9c9..1a52110d7a 100644 --- a/backend/src/api/eagleEyeContent/eagleEyeContentList.ts +++ b/backend/src/api/eagleEyeContent/eagleEyeContentReply.ts @@ -4,14 +4,18 @@ import EagleEyeContentService from '../../services/eagleEyeContentService' import PermissionChecker from '../../services/user/permissionChecker' export default async (req, res) => { - new PermissionChecker(req).validateHas(Permissions.values.eagleEyeContentRead) - const payload = await new EagleEyeContentService(req).findAndCountAll(req.query) + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeActionCreate) - if (req.query.filter && Object.keys(req.query.filter).length > 0) { - const platforms = req.query.filter.platforms ? req.query.filter.platforms.split(',') : [] - const nDays = req.query.filter.nDays - track('Eagle Eye Filter', { filter: req.query.filter, platforms, nDays }, { ...req }) - } + const payload = await EagleEyeContentService.reply(req.query.title, req.query.description) + track( + 'Eagle Eye reply generated', + { + title: req.query.title, + description: req.query.description, + reply: payload.reply, + }, + { ...req }, + ) await req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/eagleEyeContent/eagleEyeContentSearch.ts b/backend/src/api/eagleEyeContent/eagleEyeContentSearch.ts index 278459e1c3..88659e218a 100644 --- a/backend/src/api/eagleEyeContent/eagleEyeContentSearch.ts +++ b/backend/src/api/eagleEyeContent/eagleEyeContentSearch.ts @@ -4,11 +4,9 @@ import EagleEyeContentService from '../../services/eagleEyeContentService' import PermissionChecker from '../../services/user/permissionChecker' export default async (req, res) => { - new PermissionChecker(req).validateHas(Permissions.values.eagleEyeContentSearch) - - const payload = await new EagleEyeContentService(req).search(req.body) - - track('EagleEyeSearch', { ...req.body }, { ...req }) + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeActionCreate) + const payload = await new EagleEyeContentService(req).search() + track('EagleEye backend search', { ...req.body }, { ...req }) await req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/eagleEyeContent/eagleEyeContentTrack.ts b/backend/src/api/eagleEyeContent/eagleEyeContentTrack.ts new file mode 100644 index 0000000000..29f25c246b --- /dev/null +++ b/backend/src/api/eagleEyeContent/eagleEyeContentTrack.ts @@ -0,0 +1,99 @@ +import Permissions from '../../security/permissions' +import PermissionChecker from '../../services/user/permissionChecker' +import track from '../../segment/track' +import Error404 from '../../errors/Error404' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeContentRead) + + const event = req.body.event + const params = req.body.params + + switch (event) { + case 'postClicked': + console.log('Eagle Eye post clicked', { + url: params.url, + platform: params.platform, + }) + track( + 'Eagle Eye post clicked', + { + url: params.url, + platform: params.platform, + }, + { ...req }, + ) + break + case 'generatedReply': + console.log('Eagle Eye AI reply generated', { + title: params.title, + description: params.description, + platform: params.platform, + reply: params.reply, + url: params.url, + }) + track( + 'Eagle Eye AI reply generated', + { + title: params.title, + description: params.description, + platform: params.platform, + reply: params.reply, + url: params.url, + }, + { ...req }, + ) + break + case 'generatedReplyFeedback': + console.log('Eagle Eye AI reply feedback', { + type: params.type, + title: params.title, + description: params.description, + platform: params.platform, + reply: params.reply, + url: params.url, + }) + track( + 'Eagle Eye AI reply feedback', + { + type: params.type, + title: params.title, + description: params.description, + platform: params.platform, + reply: params.reply, + url: params.url, + }, + { ...req }, + ) + break + case 'generatedReplyCopied': + console.log('Eagle Eye AI reply copied', { + title: params.title, + description: params.description, + platform: params.platform, + url: params.url, + reply: params.reply, + }) + track( + 'Eagle Eye AI reply copied', + { + title: params.title, + description: params.description, + platform: params.platform, + url: params.url, + reply: params.reply, + }, + { ...req }, + ) + break + + default: + throw new Error404('en', 'erros.eagleEye.invlaidEvent') + } + + const out = { + Success: true, + } + + await req.responseHandler.success(req, res, out) +} diff --git a/backend/src/api/eagleEyeContent/eagleEyeContentUpdate.ts b/backend/src/api/eagleEyeContent/eagleEyeContentUpsert.ts similarity index 77% rename from backend/src/api/eagleEyeContent/eagleEyeContentUpdate.ts rename to backend/src/api/eagleEyeContent/eagleEyeContentUpsert.ts index b1d3c0854e..2acfcc5e2d 100644 --- a/backend/src/api/eagleEyeContent/eagleEyeContentUpdate.ts +++ b/backend/src/api/eagleEyeContent/eagleEyeContentUpsert.ts @@ -3,9 +3,9 @@ import EagleEyeContentService from '../../services/eagleEyeContentService' import PermissionChecker from '../../services/user/permissionChecker' export default async (req, res) => { - new PermissionChecker(req).validateHas(Permissions.values.eagleEyeContentEdit) + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeContentCreate) - const payload = await new EagleEyeContentService(req).update(req.params.id, req.body) + const payload = await new EagleEyeContentService(req).upsert(req.body) await req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/eagleEyeContent/eagleEyeSettingsUpdate.ts b/backend/src/api/eagleEyeContent/eagleEyeSettingsUpdate.ts new file mode 100644 index 0000000000..635e4a7411 --- /dev/null +++ b/backend/src/api/eagleEyeContent/eagleEyeSettingsUpdate.ts @@ -0,0 +1,11 @@ +import Permissions from '../../security/permissions' +import EagleEyeSettingsService from '../../services/eagleEyeSettingsService' +import PermissionChecker from '../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.eagleEyeActionCreate) + + const payload = await new EagleEyeSettingsService(req).update(req.body) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/eagleEyeContent/index.ts b/backend/src/api/eagleEyeContent/index.ts index 33928e9148..99d8a1ff27 100644 --- a/backend/src/api/eagleEyeContent/index.ts +++ b/backend/src/api/eagleEyeContent/index.ts @@ -1,21 +1,59 @@ import { safeWrap } from '../../middlewares/errorMiddleware' +import { featureFlagMiddleware } from '../../middlewares/featureFlagMiddleware' +import { FeatureFlag } from '../../types/common' export default (app) => { + app.post( + `/tenant/:tenantId/eagleEyeContent/query`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeContentQuery').default), + ) + app.post( `/tenant/:tenantId/eagleEyeContent`, - safeWrap(require('./eagleEyeContentSearch').default), + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeContentUpsert').default), ) + app.post( - `/tenant/:tenantId/eagleEyeContent/query`, - safeWrap(require('./eagleEyeContentQuery').default), + `/tenant/:tenantId/eagleEyeContent/track`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeContentTrack').default), ) - app.put( - `/tenant/:tenantId/eagleEyeContent/:id`, - safeWrap(require('./eagleEyeContentUpdate').default), + + app.get( + `/tenant/:tenantId/eagleEyeContent/reply`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeContentReply').default), ) - app.get(`/tenant/:tenantId/eagleEyeContent`, safeWrap(require('./eagleEyeContentList').default)) + + app.get( + `/tenant/:tenantId/eagleEyeContent/search`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeContentSearch').default), + ) + app.get( `/tenant/:tenantId/eagleEyeContent/:id`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), safeWrap(require('./eagleEyeContentFind').default), ) + + app.post( + `/tenant/:tenantId/eagleEyeContent/:contentId/action`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeActionCreate').default), + ) + + app.put( + `/tenant/:tenantId/eagleEyeContent/settings`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeSettingsUpdate').default), + ) + + app.delete( + `/tenant/:tenantId/eagleEyeContent/:contentId/action/:actionId`, + featureFlagMiddleware(FeatureFlag.EAGLE_EYE, 'entities.eagleEye.errors.planLimitExceeded'), + safeWrap(require('./eagleEyeActionDestroy').default), + ) } diff --git a/backend/src/bin/jobs/eagleEyeEmailDigestTicks.ts b/backend/src/bin/jobs/eagleEyeEmailDigestTicks.ts new file mode 100644 index 0000000000..0da13f247a --- /dev/null +++ b/backend/src/bin/jobs/eagleEyeEmailDigestTicks.ts @@ -0,0 +1,55 @@ +import { Op } from 'sequelize' +import moment from 'moment' +import SequelizeRepository from '../../database/repositories/sequelizeRepository' +import { CrowdJob } from '../../types/jobTypes' +import { sendNodeWorkerMessage } from '../../serverless/utils/nodeWorkerSQS' +import { NodeWorkerMessageType } from '../../serverless/types/workerTypes' +import { NodeWorkerMessageBase } from '../../types/mq/nodeWorkerMessageBase' + +const job: CrowdJob = { + name: 'Eagle Eye Email Digest Ticker', + // every half hour + cronTime: '*/30 * * * *', + onTrigger: async () => { + const options = await SequelizeRepository.getDefaultIRepositoryOptions() + const users = ( + await options.database.user.findAll({ + where: { + [Op.and]: [ + { + 'eagleEyeSettings.emailDigestActive': { + [Op.ne]: null, + }, + }, + { + 'eagleEyeSettings.emailDigestActive': { + [Op.eq]: true, + }, + }, + ], + }, + include: [ + { + model: options.database.tenantUser, + as: 'tenants', + }, + ], + }) + ).filter( + (u) => + u.eagleEyeSettings && + u.eagleEyeSettings.emailDigestActive && + moment() > moment(u.eagleEyeSettings.emailDigest.nextEmailAt), + ) + + for (const user of users) { + await sendNodeWorkerMessage(user.id, { + type: NodeWorkerMessageType.NODE_MICROSERVICE, + user: user.id, + service: 'eagle-eye-email-digest', + } as NodeWorkerMessageBase) + } + }, +} + +export default job diff --git a/backend/src/bin/jobs/index.ts b/backend/src/bin/jobs/index.ts index 319fbe2b83..e5a3d1fd7b 100644 --- a/backend/src/bin/jobs/index.ts +++ b/backend/src/bin/jobs/index.ts @@ -5,6 +5,7 @@ import memberScoreCoordinator from './memberScoreCoordinator' import checkSqsQueues from './checkSqsQueues' import refreshMaterializedViews from './refreshMaterializedViews' import downgradeExpiredPlans from './downgradeExpiredPlans' +import eagleEyeEmailDigestTicks from './eagleEyeEmailDigestTicks' const jobs: CrowdJob[] = [ weeklyAnalyticsEmailsCoordinator, @@ -13,6 +14,7 @@ const jobs: CrowdJob[] = [ checkSqsQueues, refreshMaterializedViews, downgradeExpiredPlans, + eagleEyeEmailDigestTicks, ] export default jobs diff --git a/backend/src/bin/scripts/send-weekly-analytics-email.ts b/backend/src/bin/scripts/send-weekly-analytics-email.ts index 939f2ec45d..520434061f 100644 --- a/backend/src/bin/scripts/send-weekly-analytics-email.ts +++ b/backend/src/bin/scripts/send-weekly-analytics-email.ts @@ -9,7 +9,8 @@ import { timeout } from '../../utils/timing' import { sendNodeWorkerMessage } from '../../serverless/utils/nodeWorkerSQS' import { NodeWorkerMessageType } from '../../serverless/types/workerTypes' import { NodeWorkerMessageBase } from '../../types/mq/nodeWorkerMessageBase' -import WeeklyAnalyticsEmailsHistoryRepository from '../../database/repositories/weeklyAnalyticsEmailsHistoryRepository' +import RecurringEmailsHistoryRepository from '../../database/repositories/recurringEmailsHistoryRepository' +import { RecurringEmailType } from '../../types/recurringEmailsHistoryTypes' const banner = fs.readFileSync(path.join(__dirname, 'banner.txt'), 'utf8') @@ -55,12 +56,16 @@ if (parameters.help || !parameters.tenant) { const options = await SequelizeRepository.getDefaultIRepositoryOptions() const tenantIds = parameters.tenant.split(',') const weekOfYear = moment().utc().startOf('isoWeek').subtract(7, 'days').isoWeek().toString() - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(options) + const rehRepository = new RecurringEmailsHistoryRepository(options) for (const tenantId of tenantIds) { const tenant = await options.database.tenant.findByPk(tenantId) const isEmailAlreadySent = - (await waeRepository.findByWeekOfYear(tenantId, weekOfYear)) !== null + (await rehRepository.findByWeekOfYear( + tenantId, + weekOfYear, + RecurringEmailType.WEEKLY_ANALYTICS, + )) !== null if (!tenant) { log.error({ tenantId }, 'Tenant not found! Skipping.') diff --git a/backend/src/config/configTypes.ts b/backend/src/config/configTypes.ts index c0949978df..a09768f3f2 100644 --- a/backend/src/config/configTypes.ts +++ b/backend/src/config/configTypes.ts @@ -156,6 +156,7 @@ export interface SendgridConfiguration { templateWeeklyAnalytics: string templateIntegrationDone: string templateCsvExport: string + templateEagleEyeDigest: string weeklyAnalyticsUnsubscribeGroupId: string } @@ -179,3 +180,8 @@ export interface EnrichmentConfiguration { url: string apiKey: string } + +export interface EagleEyeConfiguration { + url: string + apiKey: string +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 6d32e96932..0e0f37b7b5 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -24,6 +24,7 @@ import { PosthogConfiguration, PizzlyConfiguration, EnrichmentConfiguration, + EagleEyeConfiguration, } from './configTypes' // TODO-kube @@ -203,6 +204,7 @@ export const SENDGRID_CONFIG: SendgridConfiguration = KUBE_MODE templateWeeklyAnalytics: process.env.SENDGRID_TEMPLATE_WEEKLY_ANALYTICS, templateIntegrationDone: process.env.SENDGRID_TEMPLATE_INTEGRATION_DONE, templateCsvExport: process.env.SENDGRID_TEMPLATE_CSV_EXPORT, + templateEagleEyeDigest: process.env.SENDGRID_TEMPLATE_EAGLE_EYE_DIGEST, } export const NETLIFY_CONFIG: NetlifyConfiguration = KUBE_MODE @@ -233,3 +235,10 @@ export const ENRICHMENT_CONFIG: EnrichmentConfiguration = KUBE_MODE url: process.env.ENRICHMENT_URL, apiKey: process.env.ENRICHMENT_SECRET_KEY, } + +export const EAGLE_EYE_CONFIG: EagleEyeConfiguration = KUBE_MODE + ? config.get('eagleEye') + : { + url: process.env.EAGLE_EYE_URL, + apiKey: process.env.EAGLE_EYE_SECRET_KEY, + } diff --git a/backend/src/database/migrations/U1675259471__eagleEyeActions.sql b/backend/src/database/migrations/U1675259471__eagleEyeActions.sql new file mode 100644 index 0000000000..30b8b2c037 --- /dev/null +++ b/backend/src/database/migrations/U1675259471__eagleEyeActions.sql @@ -0,0 +1,59 @@ +DROP TABLE IF EXISTS "eagleEyeContents"; + +DROP TYPE "eagleEyeContents_actions_type"; + +create table "eagleEyeContents"; +( + id uuid not null primary key, + "sourceId" text not null, + "vectorId" text not null, + status varchar(255) default NULL::character varying, + title text not null, + username text not null, + url text not null, + text text, + timestamp timestamp with time zone not null, + platform text not null, + keywords text [], + "similarityScore" double precision, + "userAttributes" jsonb, + "postAttributes" jsonb, + "importHash" varchar(255), + "createdAt" timestamp with time zone not null, + "updatedAt" timestamp with time zone not null, + "deletedAt" timestamp with time zone, + "tenantId" uuid not null references tenants on update cascade, + "createdById" uuid references users on update cascade on delete + set null, + "updatedById" uuid references users on update cascade on delete + set null, + "exactKeywords" text [] +); + +alter table "eagleEyeContents" owner to postgres; + +create index discord on "eagleEyeContents" ("vectorId", status); + +create index members_email_tenant_id on "eagleEyeContents" (id) +where ("deletedAt" IS NULL); + +create index members_joined_at_tenant_id on "eagleEyeContents" (id) +where ("deletedAt" IS NULL); + +create index members_username on "eagleEyeContents" using gin (id); + +create index slack on "eagleEyeContents" (id); + +create index twitter on "eagleEyeContents" (id); + +create unique index eagle_eye_contents_import_hash_tenant_id on "eagleEyeContents" ("importHash", "tenantId") +where ("deletedAt" IS NULL); + +create index eagle_eye_contents_platform_tenant_id_timestamp on "eagleEyeContents" (platform, "tenantId", timestamp) +where ("deletedAt" IS NULL); + +create index eagle_eye_contents_status_tenant_id_timestamp on "eagleEyeContents" (status, "tenantId", timestamp) +where ("deletedAt" IS NULL); + +create index eagle_eye_contents_tenant_id_timestamp on "eagleEyeContents" ("tenantId", timestamp) +where ("deletedAt" IS NULL); \ No newline at end of file diff --git a/backend/src/database/migrations/U1675702339__eagleEyeSettings.sql b/backend/src/database/migrations/U1675702339__eagleEyeSettings.sql new file mode 100644 index 0000000000..e3a0f4fe1e --- /dev/null +++ b/backend/src/database/migrations/U1675702339__eagleEyeSettings.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."users" +DROP COLUMN "eagleEyeSettings"; \ No newline at end of file diff --git a/backend/src/database/migrations/U1676555340__recurringEmailsHistory.sql b/backend/src/database/migrations/U1676555340__recurringEmailsHistory.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/database/migrations/V1675259471__eagleEyeActions.sql b/backend/src/database/migrations/V1675259471__eagleEyeActions.sql new file mode 100644 index 0000000000..9183281bc1 --- /dev/null +++ b/backend/src/database/migrations/V1675259471__eagleEyeActions.sql @@ -0,0 +1,33 @@ +DROP TABLE IF EXISTS "eagleEyeContents"; + +CREATE TABLE public."eagleEyeContents" ( + "id" uuid NOT NULL, + "platform" text NOT NULL, + "url" text NOT NULL, + "post" jsonb NOT NULL, + "tenantId" uuid NOT NULL, + "postedAt" timestamptz NOT NULL, + "createdAt" timestamptz NOT NULL, + "updatedAt" timestamptz NOT NULL, + CONSTRAINT "eagleEyeContents_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE public."eagleEyeContents" ADD CONSTRAINT "eagleEyeContents_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES public.tenants(id) ON DELETE NO ACTION ON UPDATE NO ACTION; + +CREATE TYPE public."eagleEyeActionTypes_type" AS ENUM ('thumbs-up', 'thumbs-down', 'bookmark'); + +CREATE TABLE public."eagleEyeActions" ( + "id" uuid NOT NULL, + "type" public."eagleEyeActionTypes_type" NOT NULL, + "timestamp" timestamptz NOT NULL, + "contentId" uuid NOT NULL, + "tenantId" uuid NOT NULL, + "actionById" uuid NOT NULL, + "createdAt" timestamptz NOT NULL, + "updatedAt" timestamptz NOT NULL, + CONSTRAINT "eagleEyeActions_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE public."eagleEyeActions" ADD CONSTRAINT "eagleEyeActions_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES public.tenants(id) ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE public."eagleEyeActions" ADD CONSTRAINT "eagleEyeActions_actionBy_fkey" FOREIGN KEY ("actionById") REFERENCES public.users(id) ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE public."eagleEyeActions" ADD CONSTRAINT "eagleEyeActions_contentId_fkey" FOREIGN KEY ("contentId") REFERENCES public."eagleEyeContents"(id) ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/backend/src/database/migrations/V1675702339__eagleEyeSettings.sql b/backend/src/database/migrations/V1675702339__eagleEyeSettings.sql new file mode 100644 index 0000000000..7e8e46fbf2 --- /dev/null +++ b/backend/src/database/migrations/V1675702339__eagleEyeSettings.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."users" +ADD COLUMN "eagleEyeSettings" JSONB DEFAULT '{"onboarded": false}'; diff --git a/backend/src/database/migrations/V1676555340__recurringEmailsHistory.sql b/backend/src/database/migrations/V1676555340__recurringEmailsHistory.sql new file mode 100644 index 0000000000..41f7d5ce83 --- /dev/null +++ b/backend/src/database/migrations/V1676555340__recurringEmailsHistory.sql @@ -0,0 +1,12 @@ +ALTER TABLE public."weeklyAnalyticsEmailsHistory" RENAME TO "recurringEmailsHistory"; + +CREATE TYPE public."recurringEmailTypes_type" AS ENUM ('weekly-analytics', 'eagle-eye-digest'); + +ALTER TABLE "recurringEmailsHistory" ADD COLUMN "type" public."recurringEmailTypes_type"; + +UPDATE "recurringEmailsHistory" SET "type"='weekly-analytics'; + +ALTER TABLE "recurringEmailsHistory" ALTER COLUMN "type" SET NOT NULL; +ALTER TABLE "recurringEmailsHistory" ALTER COLUMN "weekOfYear" DROP NOT NULL; + + diff --git a/backend/src/database/models/eagleEyeAction.ts b/backend/src/database/models/eagleEyeAction.ts new file mode 100644 index 0000000000..116b069fb2 --- /dev/null +++ b/backend/src/database/models/eagleEyeAction.ts @@ -0,0 +1,48 @@ +import { DataTypes } from 'sequelize' + +const eagleEyeActionModel = { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + type: { + type: DataTypes.TEXT, + validate: { + isIn: [['thumbs-up', 'thumbs-down', 'bookmark']], + }, + defaultValue: null, + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false, + }, +} + +export default (sequelize) => { + const eagleEyeAction = sequelize.define('eagleEyeAction', eagleEyeActionModel, { + timestamps: true, + paranoid: false, + }) + + eagleEyeAction.associate = (models) => { + models.eagleEyeAction.belongsTo(models.tenant, { + as: 'tenant', + foreignKey: { + allowNull: false, + }, + }) + + models.eagleEyeAction.belongsTo(models.user, { + as: 'actionBy', + }) + + models.eagleEyeAction.belongsTo(models.eagleEyeContent, { + as: 'content', + }) + } + + return eagleEyeAction +} + +export { eagleEyeActionModel } diff --git a/backend/src/database/models/eagleEyeContent.ts b/backend/src/database/models/eagleEyeContent.ts index bfee48a92e..9b00bc070c 100644 --- a/backend/src/database/models/eagleEyeContent.ts +++ b/backend/src/database/models/eagleEyeContent.ts @@ -6,40 +6,16 @@ const eagleEyeContentModel = { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, - sourceId: { - type: DataTypes.TEXT, - allowNull: false, - validate: { - notEmpty: true, - }, - }, - vectorId: { - type: DataTypes.TEXT, - allowNull: false, - validate: { - notEmpty: true, - }, - }, - status: { - type: DataTypes.STRING(255), - validate: { - isIn: [['engaged', 'rejected']], - }, - defaultValue: null, - }, - title: { + platform: { type: DataTypes.TEXT, allowNull: false, validate: { notEmpty: true, }, }, - username: { - type: DataTypes.TEXT, + post: { + type: DataTypes.JSONB, allowNull: false, - validate: { - notEmpty: true, - }, }, url: { type: DataTypes.TEXT, @@ -48,79 +24,16 @@ const eagleEyeContentModel = { notEmpty: true, }, }, - text: { - type: DataTypes.TEXT, - }, - timestamp: { + postedAt: { type: DataTypes.DATE, allowNull: false, }, - platform: { - type: DataTypes.TEXT, - allowNull: false, - validate: { - notEmpty: true, - }, - }, - keywords: { - type: DataTypes.ARRAY(DataTypes.TEXT), - default: [], - }, - exactKeywords: { - type: DataTypes.ARRAY(DataTypes.TEXT), - default: [], - }, - similarityScore: { - type: DataTypes.FLOAT, - }, - userAttributes: { - type: DataTypes.JSONB, - default: {}, - }, - postAttributes: { - type: DataTypes.JSONB, - default: {}, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - validate: { - len: [0, 255], - }, - }, } export default (sequelize) => { const eagleEyeContent = sequelize.define('eagleEyeContent', eagleEyeContentModel, { - indexes: [ - { - unique: true, - fields: ['importHash', 'tenantId'], - where: { - deletedAt: null, - }, - }, - { - fields: ['platform', 'tenantId', 'timestamp'], - where: { - deletedAt: null, - }, - }, - { - fields: ['status', 'tenantId', 'timestamp'], - where: { - deletedAt: null, - }, - }, - { - fields: ['tenantId', 'timestamp'], - where: { - deletedAt: null, - }, - }, - ], timestamps: true, - paranoid: true, + paranoid: false, }) eagleEyeContent.associate = (models) => { @@ -130,13 +43,9 @@ export default (sequelize) => { allowNull: false, }, }) - - models.eagleEyeContent.belongsTo(models.user, { - as: 'createdBy', - }) - - models.eagleEyeContent.belongsTo(models.user, { - as: 'updatedBy', + models.eagleEyeContent.hasMany(models.eagleEyeAction, { + as: 'actions', + foreignKey: 'contentId', }) } diff --git a/backend/src/database/models/index.ts b/backend/src/database/models/index.ts index 9a1dcc704c..4e0b3bcc09 100644 --- a/backend/src/database/models/index.ts +++ b/backend/src/database/models/index.ts @@ -102,6 +102,7 @@ function models() { require('./conversation').default, require('./conversationSettings').default, require('./eagleEyeContent').default, + require('./eagleEyeAction').default, require('./automation').default, require('./automationExecution').default, require('./organization').default, diff --git a/backend/src/database/models/user.ts b/backend/src/database/models/user.ts index a64482691b..3e9990465e 100644 --- a/backend/src/database/models/user.ts +++ b/backend/src/database/models/user.ts @@ -102,6 +102,13 @@ export default (sequelize, DataTypes) => { len: [0, 255], }, }, + eagleEyeSettings: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: { + onboarded: false, + }, + }, }, { indexes: [ diff --git a/backend/src/database/repositories/__tests__/eagleEyeActionRepository.test.ts b/backend/src/database/repositories/__tests__/eagleEyeActionRepository.test.ts new file mode 100644 index 0000000000..f3a64fa6ec --- /dev/null +++ b/backend/src/database/repositories/__tests__/eagleEyeActionRepository.test.ts @@ -0,0 +1,63 @@ +import EagleEyeContentRepository from '../eagleEyeContentRepository' +import SequelizeTestUtils from '../../utils/sequelizeTestUtils' +import { EagleEyeAction, EagleEyeActionType, EagleEyeContent } from '../../../types/eagleEyeTypes' +import EagleEyeActionRepository from '../eagleEyeActionRepository' + +const db = null + +describe('eagleEyeActionRepository tests', () => { + beforeEach(async () => { + await SequelizeTestUtils.wipeDatabase(db) + }) + + afterAll((done) => { + // Closing the DB connection allows Jest to exit successfully. + SequelizeTestUtils.closeConnection(db) + done() + }) + + describe('createActionForContent method', () => { + it('Should create a an action for a content succesfully', async () => { + const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) + + const content = { + platform: 'reddit', + url: 'https://some-post-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2020-05-27T15:13:30Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + } as EagleEyeContent + + const contentCreated = await EagleEyeContentRepository.create(content, mockIRepositoryOptions) + + const action: EagleEyeAction = { + type: EagleEyeActionType.BOOKMARK, + timestamp: '2022-07-27T19:13:30Z', + } + + const actionCreated = await EagleEyeActionRepository.createActionForContent( + action, + contentCreated.id, + mockIRepositoryOptions, + ) + + actionCreated.createdAt = (actionCreated.createdAt as Date).toISOString().split('T')[0] + actionCreated.updatedAt = (actionCreated.updatedAt as Date).toISOString().split('T')[0] + + const expectedAction = { + id: actionCreated.id, + ...action, + timestamp: new Date(actionCreated.timestamp), + contentId: contentCreated.id, + actionById: mockIRepositoryOptions.currentUser.id, + tenantId: mockIRepositoryOptions.currentTenant.id, + createdAt: SequelizeTestUtils.getNowWithoutTime(), + updatedAt: SequelizeTestUtils.getNowWithoutTime(), + } + expect(expectedAction).toStrictEqual(actionCreated) + }) + }) +}) diff --git a/backend/src/database/repositories/__tests__/eagleEyeContentRepository.test.ts b/backend/src/database/repositories/__tests__/eagleEyeContentRepository.test.ts index bffec870ca..96666a7148 100644 --- a/backend/src/database/repositories/__tests__/eagleEyeContentRepository.test.ts +++ b/backend/src/database/repositories/__tests__/eagleEyeContentRepository.test.ts @@ -1,86 +1,10 @@ -import lodash from 'lodash' -import moment from 'moment' import EagleEyeContentRepository from '../eagleEyeContentRepository' import SequelizeTestUtils from '../../utils/sequelizeTestUtils' -import Error404 from '../../../errors/Error404' -import Error400 from '../../../errors/Error400' -import EagleEyeContentService from '../../../services/eagleEyeContentService' -import { PlatformType } from '../../../types/integrationEnums' +import { EagleEyeActionType, EagleEyeContent } from '../../../types/eagleEyeTypes' +import EagleEyeActionRepository from '../eagleEyeActionRepository' const db = null -const toCreate = { - sourceId: 'sourceId', - vectorId: '123', - status: null, - platform: 'hacker_news', - title: 'title', - userAttributes: { [PlatformType.GITHUB]: 'hey', [PlatformType.TWITTER]: 'ho' }, - text: 'text', - postAttributes: { - score: 10, - }, - url: 'url', - exactKeywords: null, - timestamp: new Date(), - username: 'username', - keywords: ['keyword1', 'keyword2'], - similarityScore: 0.9, -} - -const toCreateMinimal = { - sourceId: 'sourceIdMinimal', - vectorId: '456', - platform: 'hacker_news', - url: 'url', - title: 'title minimal', - timestamp: new Date(), - username: 'username', - keywords: 'keyword', -} - -const forFiltering = [ - toCreate, - toCreateMinimal, - { - sourceId: 'devto123', - vectorId: '123123', - status: 'engaged', - url: 'devto url', - username: 'devtousername1', - platform: 'devto', - timestamp: moment().toDate(), - title: 'title devto 1', - }, - { - sourceId: 'devto456', - vectorId: '123456', - url: 'url devto 2', - username: 'devtousername2', - status: 'rejected', - platform: 'devto', - timestamp: moment().subtract(1, 'week').toDate(), - title: 'title devto 2', - keywords: ['keyword1', 'keyword2'], - score: 40, - }, - { - sourceId: 'devto789', - vectorId: '123456', - url: 'url devto 3', - username: 'devtousername3', - status: null, - platform: 'devto', - timestamp: moment().subtract(1, 'week').toDate(), - keywords: ['keyword3', 'keyword2'], - title: 'title devto 3', - }, -] - -async function addAll(options) { - await Promise.all(forFiltering.map((item) => EagleEyeContentRepository.upsert(item, options))) -} - describe('eagleEyeContentRepository tests', () => { beforeEach(async () => { await SequelizeTestUtils.wipeDatabase(db) @@ -92,29 +16,40 @@ describe('eagleEyeContentRepository tests', () => { done() }) - describe('upserts method', () => { - it('Should create a complete content succesfully', async () => { + describe('create method', () => { + it('Should create a content succesfully', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const created = await EagleEyeContentRepository.upsert(toCreate, mockIRepositoryOptions) + const content = { + platform: 'reddit', + url: 'https://some-post-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2020-05-27T15:13:30Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + } as EagleEyeContent - created.createdAt = created.createdAt.toISOString().split('T')[0] - created.updatedAt = created.updatedAt.toISOString().split('T')[0] + const created = await EagleEyeContentRepository.create(content, mockIRepositoryOptions) + + created.createdAt = (created.createdAt as Date).toISOString().split('T')[0] + created.updatedAt = (created.updatedAt as Date).toISOString().split('T')[0] const expectedCreated = { id: created.id, - ...toCreate, - importHash: null, + ...content, + postedAt: new Date(content.postedAt), + actions: [], createdAt: SequelizeTestUtils.getNowWithoutTime(), updatedAt: SequelizeTestUtils.getNowWithoutTime(), - deletedAt: null, tenantId: mockIRepositoryOptions.currentTenant.id, - createdById: mockIRepositoryOptions.currentUser.id, - updatedById: mockIRepositoryOptions.currentUser.id, } expect(created).toStrictEqual(expectedCreated) }) + /* + it('Should create a content with unix timestamp', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) @@ -157,130 +92,9 @@ describe('eagleEyeContentRepository tests', () => { expect(created).toStrictEqual(expectedCreated) }) - it('Should not add it the record already exists', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - - await EagleEyeContentRepository.upsert(toCreate, mockIRepositoryOptions) - - await EagleEyeContentRepository.upsert(toCreate, mockIRepositoryOptions) - - const count = await mockIRepositoryOptions.database.eagleEyeContent.count() - expect(count).toBe(1) - }) - - it('Should update keywords and similarity score if the item already exists', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - - await EagleEyeContentRepository.upsert(toCreate, mockIRepositoryOptions) - - const toCreateNewKeywords = { ...toCreate } - toCreateNewKeywords.keywords = ['1', '2', 'keyword1'] - toCreateNewKeywords.similarityScore = 0.95 - - const allKeywords = ['1', '2', 'keyword1', 'keyword2'] - - const created = await EagleEyeContentRepository.upsert( - toCreateNewKeywords, - mockIRepositoryOptions, - ) - - const count = await mockIRepositoryOptions.database.eagleEyeContent.count() - expect(count).toBe(1) - expect(lodash.isEqual(created.keywords.sort(), allKeywords.sort())) - expect(created.similarityScore).toBe(0.95) - }) - - it('Should create a minimal content succesfully', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - - const created = await EagleEyeContentRepository.upsert( - toCreateMinimal, - mockIRepositoryOptions, - ) - - created.createdAt = created.createdAt.toISOString().split('T')[0] - created.updatedAt = created.updatedAt.toISOString().split('T')[0] - - const expectedCreated = { - id: created.id, - ...toCreateMinimal, - text: null, - status: null, - userAttributes: null, - postAttributes: null, - similarityScore: null, - exactKeywords: null, - importHash: null, - createdAt: SequelizeTestUtils.getNowWithoutTime(), - updatedAt: SequelizeTestUtils.getNowWithoutTime(), - deletedAt: null, - tenantId: mockIRepositoryOptions.currentTenant.id, - createdById: mockIRepositoryOptions.currentUser.id, - updatedById: mockIRepositoryOptions.currentUser.id, - } - expect(created).toStrictEqual(expectedCreated) - expect(created.status).toBe(null) - }) - - it('Should create with rejected status', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const newStatus = { ...toCreate } - newStatus.status = 'rejected' - - const created = await EagleEyeContentRepository.upsert(newStatus, mockIRepositoryOptions) - - created.createdAt = created.createdAt.toISOString().split('T')[0] - created.updatedAt = created.updatedAt.toISOString().split('T')[0] + - const expectedCreated = { - id: created.id, - ...newStatus, - importHash: null, - createdAt: SequelizeTestUtils.getNowWithoutTime(), - updatedAt: SequelizeTestUtils.getNowWithoutTime(), - deletedAt: null, - tenantId: mockIRepositoryOptions.currentTenant.id, - createdById: mockIRepositoryOptions.currentUser.id, - updatedById: mockIRepositoryOptions.currentUser.id, - } - expect(created).toStrictEqual(expectedCreated) - expect(created.status).toBe('rejected') - }) - - it('Should create with engaged status', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const newStatus = { ...toCreate } - newStatus.status = 'engaged' - - const created = await EagleEyeContentRepository.upsert(newStatus, mockIRepositoryOptions) - - created.createdAt = created.createdAt.toISOString().split('T')[0] - created.updatedAt = created.updatedAt.toISOString().split('T')[0] - - const expectedCreated = { - id: created.id, - ...newStatus, - importHash: null, - createdAt: SequelizeTestUtils.getNowWithoutTime(), - updatedAt: SequelizeTestUtils.getNowWithoutTime(), - deletedAt: null, - tenantId: mockIRepositoryOptions.currentTenant.id, - createdById: mockIRepositoryOptions.currentUser.id, - updatedById: mockIRepositoryOptions.currentUser.id, - } - expect(created).toStrictEqual(expectedCreated) - expect(created.status).toBe('engaged') - }) - - it('Should throw an error for an invalid status', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const newStatus = { ...toCreate } - newStatus.status = 'smth else' - - await expect(() => - EagleEyeContentRepository.upsert(newStatus, mockIRepositoryOptions), - ).rejects.toThrowError(new Error400('en', 'errors.invalidEagleEyeStatus.message')) - }) + }) describe('find by id method', () => { @@ -601,4 +415,138 @@ describe('eagleEyeContentRepository tests', () => { expect(updated.keywords).toStrictEqual(created.keywords) }) }) + */ + }) + + describe('findAndCountAll method', () => { + it('Should find eagle eye contant, various cases', async () => { + // create random tenant with one user + const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) + + // create additional users for same tenant to test out actionBy filtering + const randomUser = await SequelizeTestUtils.getRandomUser() + + console.log('random user: ') + console.log(randomUser) + + const user2 = await mockIRepositoryOptions.database.user.create(randomUser) + + await mockIRepositoryOptions.database.tenantUser.create({ + roles: ['admin'], + status: 'active', + tenantId: mockIRepositoryOptions.currentTenant.id, + userId: user2.id, + }) + + // create few content + // one without any actions + await EagleEyeContentRepository.create( + { + platform: 'reddit', + url: 'https://some-reddit-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2020-05-27T15:13:30Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + }, + mockIRepositoryOptions, + ) + + // one with a bookmark action + let c2 = await EagleEyeContentRepository.create( + { + platform: 'hackernews', + url: 'https://some-hackernews-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2022-06-27T19:14:44Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + }, + mockIRepositoryOptions, + ) + + // add bookmark action + await EagleEyeActionRepository.createActionForContent( + { + type: EagleEyeActionType.BOOKMARK, + timestamp: '2022-07-27T19:13:30Z', + }, + c2.id, + mockIRepositoryOptions, + ) + + c2 = await EagleEyeContentRepository.findById(c2.id, mockIRepositoryOptions) + + // another content with a thumbs-up(user1) and a bookmark(user2) action + let c3 = await EagleEyeContentRepository.create( + { + platform: 'devto', + url: 'https://some-devto-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2022-06-27T19:14:44Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + }, + mockIRepositoryOptions, + ) + + // add the thumbs up action + await EagleEyeActionRepository.createActionForContent( + { + type: EagleEyeActionType.THUMBS_UP, + timestamp: '2022-09-30T23:11:10Z', + }, + c3.id, + mockIRepositoryOptions, + ) + + // also add bookmark from user2 + await EagleEyeActionRepository.createActionForContent( + { + type: EagleEyeActionType.BOOKMARK, + timestamp: '2022-09-30T23:11:10Z', + }, + c3.id, + { ...mockIRepositoryOptions, currentUser: user2 }, + ) + + c3 = await EagleEyeContentRepository.findById(c3.id, mockIRepositoryOptions) + + // filter by action type + let res = await EagleEyeContentRepository.findAndCountAll( + { + advancedFilter: { + action: { + type: EagleEyeActionType.BOOKMARK, + }, + }, + }, + mockIRepositoryOptions, + ) + + expect(res.count).toBe(2) + expect(res.rows.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1))).toStrictEqual([c2, c3]) + + // filter by actionBy + res = await EagleEyeContentRepository.findAndCountAll( + { + advancedFilter: { + action: { + actionById: user2.id, + }, + }, + }, + mockIRepositoryOptions, + ) + + expect(res.count).toBe(1) + expect(res.rows).toStrictEqual([c3]) + }) + }) }) diff --git a/backend/src/database/repositories/__tests__/weeklyAnalyticsEmailsHistoryRepository.test.ts b/backend/src/database/repositories/__tests__/recurringEmailsHistoryRepository.test.ts similarity index 64% rename from backend/src/database/repositories/__tests__/weeklyAnalyticsEmailsHistoryRepository.test.ts rename to backend/src/database/repositories/__tests__/recurringEmailsHistoryRepository.test.ts index d9da16c7de..8eaf8b98b8 100644 --- a/backend/src/database/repositories/__tests__/weeklyAnalyticsEmailsHistoryRepository.test.ts +++ b/backend/src/database/repositories/__tests__/recurringEmailsHistoryRepository.test.ts @@ -1,12 +1,15 @@ import { randomUUID } from 'crypto' import SequelizeTestUtils from '../../utils/sequelizeTestUtils' -import { WeeklyAnalyticsEmailsHistoryData } from '../../../types/weeklyAnalyticsEmailsHistoryTypes' -import WeeklyAnalyticsEmailsHistoryRepository from '../weeklyAnalyticsEmailsHistoryRepository' +import { + RecurringEmailsHistoryData, + RecurringEmailType, +} from '../../../types/recurringEmailsHistoryTypes' +import RecurringEmailsHistoryRepository from '../recurringEmailsHistoryRepository' const db = null -describe('WeeklyAnalyticsEmailsHistory tests', () => { +describe('RecurringEmailsHistory tests', () => { beforeEach(async () => { await SequelizeTestUtils.wipeDatabase(db) }) @@ -18,18 +21,19 @@ describe('WeeklyAnalyticsEmailsHistory tests', () => { }) describe('create method', () => { - it('Should create weekly analytics email history with given values', async () => { + it('Should create recurring email history with given values', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const historyData: WeeklyAnalyticsEmailsHistoryData = { + const historyData: RecurringEmailsHistoryData = { emailSentAt: '2023-01-02T00:00:00Z', + type: RecurringEmailType.WEEKLY_ANALYTICS, emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: mockIRepositoryOptions.currentTenant.id, weekOfYear: '1', } - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(mockIRepositoryOptions) - const history = await waeRepository.create(historyData) + const rehRepository = new RecurringEmailsHistoryRepository(mockIRepositoryOptions) + const history = await rehRepository.create(historyData) expect(new Date(historyData.emailSentAt)).toStrictEqual(history.emailSentAt) expect(historyData.emailSentTo).toStrictEqual(history.emailSentTo) @@ -40,40 +44,43 @@ describe('WeeklyAnalyticsEmailsHistory tests', () => { it('Should throw an error when mandatory fields are missing', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(mockIRepositoryOptions) + const rehRepository = new RecurringEmailsHistoryRepository(mockIRepositoryOptions) await expect(() => - waeRepository.create({ + rehRepository.create({ emailSentAt: '2023-01-02T00:00:00Z', emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: mockIRepositoryOptions.currentTenant.id, - weekOfYear: undefined, + type: undefined, }), ).rejects.toThrowError() await expect(() => - waeRepository.create({ + rehRepository.create({ emailSentAt: undefined, emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: mockIRepositoryOptions.currentTenant.id, weekOfYear: '1', + type: RecurringEmailType.WEEKLY_ANALYTICS, }), ).rejects.toThrowError() await expect(() => - waeRepository.create({ + rehRepository.create({ emailSentAt: '2023-01-02T00:00:00Z', emailSentTo: undefined, tenantId: mockIRepositoryOptions.currentTenant.id, weekOfYear: '1', + type: RecurringEmailType.WEEKLY_ANALYTICS, }), ).rejects.toThrowError() await expect(() => - waeRepository.create({ + rehRepository.create({ emailSentAt: '2023-01-02T00:00:00Z', emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: undefined, weekOfYear: '1', + type: RecurringEmailType.WEEKLY_ANALYTICS, }), ).rejects.toThrowError() }) @@ -83,26 +90,27 @@ describe('WeeklyAnalyticsEmailsHistory tests', () => { it('Should find historical receipt by id', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const historyData: WeeklyAnalyticsEmailsHistoryData = { + const historyData: RecurringEmailsHistoryData = { emailSentAt: '2023-01-02T00:00:00Z', emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: mockIRepositoryOptions.currentTenant.id, weekOfYear: '1', + type: RecurringEmailType.WEEKLY_ANALYTICS, } - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(mockIRepositoryOptions) + const rehRepository = new RecurringEmailsHistoryRepository(mockIRepositoryOptions) - const receiptCreated = await waeRepository.create(historyData) - const receiptFoundById = await waeRepository.findById(receiptCreated.id) + const receiptCreated = await rehRepository.create(historyData) + const receiptFoundById = await rehRepository.findById(receiptCreated.id) expect(receiptFoundById).toStrictEqual(receiptCreated) }) it('Should return null for non-existing receipt entry', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(mockIRepositoryOptions) + const rehRepository = new RecurringEmailsHistoryRepository(mockIRepositoryOptions) - const cache = await waeRepository.findById(randomUUID()) + const cache = await rehRepository.findById(randomUUID()) expect(cache).toBeNull() }) }) @@ -111,29 +119,32 @@ describe('WeeklyAnalyticsEmailsHistory tests', () => { it('Should find historical receipt by week of year', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const historyData: WeeklyAnalyticsEmailsHistoryData = { + const historyData: RecurringEmailsHistoryData = { emailSentAt: '2023-01-02T00:00:00Z', emailSentTo: ['anil@crowd.dev', 'uros@crowd.dev'], tenantId: mockIRepositoryOptions.currentTenant.id, weekOfYear: '1', + type: RecurringEmailType.EAGLE_EYE_DIGEST, } - const waeRepository = new WeeklyAnalyticsEmailsHistoryRepository(mockIRepositoryOptions) + const rehRepository = new RecurringEmailsHistoryRepository(mockIRepositoryOptions) - const receiptCreated = await waeRepository.create(historyData) + const receiptCreated = await rehRepository.create(historyData) // should find recently created receipt - let receiptFound = await waeRepository.findByWeekOfYear( + let receiptFound = await rehRepository.findByWeekOfYear( mockIRepositoryOptions.currentTenant.id, '1', + RecurringEmailType.EAGLE_EYE_DIGEST, ) expect(receiptCreated).toStrictEqual(receiptFound) // shouldn't find any receipts - receiptFound = await waeRepository.findByWeekOfYear( + receiptFound = await rehRepository.findByWeekOfYear( mockIRepositoryOptions.currentTenant.id, '2', + RecurringEmailType.EAGLE_EYE_DIGEST, ) expect(receiptFound).toBeNull() diff --git a/backend/src/database/repositories/eagleEyeActionRepository.ts b/backend/src/database/repositories/eagleEyeActionRepository.ts new file mode 100644 index 0000000000..cc7932b257 --- /dev/null +++ b/backend/src/database/repositories/eagleEyeActionRepository.ts @@ -0,0 +1,125 @@ +import lodash from 'lodash' +import Error404 from '../../errors/Error404' +import { EagleEyeAction, EagleEyeActionType } from '../../types/eagleEyeTypes' +import { IRepositoryOptions } from './IRepositoryOptions' +import SequelizeRepository from './sequelizeRepository' + +export default class EagleEyeActionRepository { + static async createActionForContent( + data: EagleEyeAction, + contentId: string, + options: IRepositoryOptions, + ): Promise { + const currentUser = SequelizeRepository.getCurrentUser(options) + + const transaction = SequelizeRepository.getTransaction(options) + + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const record = await options.database.eagleEyeAction.create( + { + ...lodash.pick(data, ['type', 'timestamp']), + actionById: currentUser.id, + contentId, + tenantId: currentTenant.id, + }, + { + transaction, + }, + ) + + return this.findById(record.id, options) + } + + static async removeActionFromContent( + action: EagleEyeActionType, + contentId: string, + options: IRepositoryOptions, + ) { + const currentUser = SequelizeRepository.getCurrentUser(options) + + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const transaction = SequelizeRepository.getTransaction(options) + + const record = await options.database.eagleEyeAction.findOne({ + where: { + contentId, + action, + actionById: currentUser.id, + tenantId: currentTenant.id, + }, + transaction, + }) + + if (record) { + await record.destroy({ + transaction, + force: true, + }) + } + } + + static async destroy(id: string, options: IRepositoryOptions): Promise { + const transaction = SequelizeRepository.getTransaction(options) + + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const record = await options.database.eagleEyeAction.findOne({ + where: { + id, + tenantId: currentTenant.id, + }, + transaction, + }) + + if (record) { + await record.destroy({ + transaction, + force: true, + }) + } + } + + static async findById(id: string, options: IRepositoryOptions) { + const transaction = SequelizeRepository.getTransaction(options) + + const include = [] + + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const record = await options.database.eagleEyeAction.findOne({ + where: { + id, + tenantId: currentTenant.id, + }, + include, + transaction, + }) + + if (!record) { + throw new Error404() + } + + return this._populateRelations(record) + } + + static async create(data: EagleEyeAction, options: IRepositoryOptions): Promise { + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const record = options.database.eagleEyeContent.create({ + ...lodash.pick(data, ['type', 'timestamp']), + tenantId: currentTenant.id, + }) + + return this.findById(record.id, options) + } + + static async _populateRelations(record) { + if (!record) { + return record + } + + return record.get({ plain: true }) + } +} diff --git a/backend/src/database/repositories/eagleEyeContentRepository.ts b/backend/src/database/repositories/eagleEyeContentRepository.ts index 56de3e60f0..203fcfdc2c 100644 --- a/backend/src/database/repositories/eagleEyeContentRepository.ts +++ b/backend/src/database/repositories/eagleEyeContentRepository.ts @@ -1,101 +1,56 @@ -import moment from 'moment' import lodash from 'lodash' +import { Op } from 'sequelize' import SequelizeRepository from './sequelizeRepository' import Error404 from '../../errors/Error404' -import Error400 from '../../errors/Error400' -import AuditLogRepository from './auditLogRepository' import { IRepositoryOptions } from './IRepositoryOptions' +import { EagleEyeContent } from '../../types/eagleEyeTypes' import QueryParser from './filters/queryParser' import { QueryOutput } from './filters/queryTypes' +import EagleEyeActionRepository from './eagleEyeActionRepository' export default class EagleEyeContentRepository { - /** - * Create an eagle eye shown content record. - * @param data Data to a new EagleEyeContent record. - * @param options Repository options. - * @returns Created EagleEyeContent record. - */ - static async upsert(data, options: IRepositoryOptions) { - const currentUser = SequelizeRepository.getCurrentUser(options) + static async create( + data: EagleEyeContent, + options: IRepositoryOptions, + ): Promise { + const currentTenant = SequelizeRepository.getCurrentTenant(options) + + const record = await options.database.eagleEyeContent.create({ + ...lodash.pick(data, ['platform', 'post', 'url', 'postedAt']), + tenantId: currentTenant.id, + }) + + if (data.actions) { + for (const action of data.actions) { + await EagleEyeActionRepository.createActionForContent(action, record.id, options) + } + } - const tenant = SequelizeRepository.getCurrentTenant(options) + return this.findById(record.id, options) + } + + static async update(id, data, options: IRepositoryOptions) { + const currentUser = SequelizeRepository.getCurrentUser(options) const transaction = SequelizeRepository.getTransaction(options) - if (data.status && ![null, 'rejected', 'engaged'].includes(data.status)) { - throw new Error400('en', 'errors.invalidEagleEyeStatus.message') - } + const currentTenant = SequelizeRepository.getCurrentTenant(options) - const existing = await options.database.eagleEyeContent.findOne({ + let record = await options.database.eagleEyeContent.findOne({ where: { - tenantId: tenant.id, - sourceId: data.sourceId, + id, + tenantId: currentTenant.id, }, + transaction, }) - // If the content is already shown, we don't need to add it again - if (existing) { - // If the content comes from a different kewword, we also add it - if (!lodash.isEqual(data.keywords.sort(), existing.keywords.sort())) { - const keywords = lodash.uniq([...existing.keywords, ...data.keywords]) - let exactKeywords = null - if (data.exactKeywords && !existing.exactKeywords) { - exactKeywords = data.exactKeywords - } else if (!data.exactKeywords && existing.exactKeywords) { - exactKeywords = existing.exactKeywords - } - if (data.exactKeywords && existing.exactKeywords) { - exactKeywords = lodash.uniq([...existing.exactKeywords, ...data.exactKeywords]) - } - const similarityScore = data.similarityScore - return existing.update( - { - keywords, - similarityScore, - exactKeywords, - }, - { - transaction, - }, - ) - } - return existing - } - - if (typeof data.keywords === 'string') { - data.keywords = [data.keywords] - } - - if (typeof data.exactKeywords === 'string') { - data.exactKeywords = [data.exactKeywords] - } - - if (typeof data.timestamp === 'number') { - data.timestamp = moment.unix(data.timestamp).toDate() + if (!record) { + throw new Error404() } - const record = await options.database.eagleEyeContent.create( + record = await record.update( { - ...lodash.pick(data, [ - 'sourceId', - 'vectorId', - 'status', - 'postAttributes', - 'title', - 'username', - 'url', - 'text', - 'timestamp', - 'userAttributes', - 'platform', - 'keywords', - 'exactKeywords', - 'similarityScore', - 'importHash', - ]), - - tenantId: tenant.id, - createdById: currentUser.id, + ...lodash.pick(data, ['platform', 'post', 'postedAt', 'url']), updatedById: currentUser.id, }, { @@ -103,273 +58,147 @@ export default class EagleEyeContentRepository { }, ) - await AuditLogRepository.log( - { - entityName: 'eagleEyeContent', - entityId: record.id, - action: AuditLogRepository.CREATE, - values: data, - }, - options, - ) - return this.findById(record.id, options) } - /** - * EagleEyeContent find all records matching given criteria. - * @returns Records found. - */ - static async findAndCountAll( - { filter = {} as any, advancedFilter = null as any, limit = 0, offset = 0, orderBy = '' }, - options: IRepositoryOptions, - ) { - // If the advanced filter is empty, we construct it from the query parameter filter - if (!advancedFilter) { - advancedFilter = { and: [] } - - if (filter.id) { - advancedFilter.and.push({ - id: filter.id, - }) - } + static async findById(id: string, options: IRepositoryOptions) { + const transaction = SequelizeRepository.getTransaction(options) - if (filter.sourceId) { - advancedFilter.and.push({ - sourceId: filter.sourceId, - }) - } + const include = [ + { + model: options.database.eagleEyeAction, + as: 'actions', + }, + ] - if (filter.vectorId) { - advancedFilter.and.push({ - vectorId: filter.vectorId, - }) - } + const currentTenant = SequelizeRepository.getCurrentTenant(options) - if (filter.status) { - if (filter.status === 'NULL') { - advancedFilter.and.push({ - status: 'NULL', - }) - } else if (filter.status === 'NOT_NULL') { - advancedFilter.and.push({ - status: { - not: null, - }, - }) - } else { - advancedFilter.and.push({ - status: { - textContains: filter.status, - }, - }) - } - } + const record = await options.database.eagleEyeContent.findOne({ + where: { + id, + tenantId: currentTenant.id, + }, + include, + transaction, + }) - if (filter.timestampRange) { - const [start, end] = filter.timestampRange - - if (start !== undefined && start !== null && start !== '') { - advancedFilter.and.push({ - timestamp: { - gte: start, - }, - }) - } - - if (end !== undefined && end !== null && end !== '') { - advancedFilter.and.push({ - timestamp: { - lte: end, - }, - }) - } - } + if (!record) { + throw new Error404() + } - if (filter.platforms) { - advancedFilter.and.push({ - platform: { - or: filter.platforms.split(','), - }, - }) - } + return this._populateRelations(record) + } - if (filter.nDays) { - advancedFilter.and.push({ - timestamp: { - gte: moment().subtract(filter.nDays, 'days').toDate(), - }, - }) - } + static async destroy(id: string, options: IRepositoryOptions): Promise { + const transaction = SequelizeRepository.getTransaction(options) - if (filter.title) { - advancedFilter.and.push({ - title: { - textContains: filter.title, - }, - }) - } + const currentTenant = SequelizeRepository.getCurrentTenant(options) - if (filter.text) { - advancedFilter.and.push({ - text: { - textContains: filter.text, - }, - }) - } + const record = await options.database.eagleEyeContent.findOne({ + where: { + id, + tenantId: currentTenant.id, + }, + transaction, + }) - if (filter.url) { - advancedFilter.and.push({ - url: { - textContains: filter.url, - }, - }) - } + if (record) { + await record.destroy({ + transaction, + force: true, + }) + } + } - if (filter.username) { - advancedFilter.and.push({ - username: { - textContains: filter.username, - }, - }) - } + static async findAndCountAll( + { advancedFilter = null as any, limit = 0, offset = 0, orderBy = '' }, + options: IRepositoryOptions, + ) { + const actionsSequelizeInclude = { + model: options.database.eagleEyeAction, + as: 'actions', + required: true, + where: {}, + limit: null, + offset: 0, + } - if (filter.keywords) { - // Overlap will take a post where any keyword matches any of the filter keywords - advancedFilter.and.push({ - keywords: { - overlap: filter.keywords.split(','), - }, - }) - } + if (advancedFilter && advancedFilter.action) { + const actionQueryParser = new QueryParser({}, options) - if (filter.similarityScoreRange) { - const [start, end] = filter.similarityScoreRange - - if (start !== undefined && start !== null && start !== '') { - advancedFilter.and.push({ - similarityScore: { - gte: start, - }, - }) - } - - if (end !== undefined && end !== null && end !== '') { - advancedFilter.and.push({ - similarityScore: { - lte: end, - }, - }) - } - } + const parsedActionQuery: QueryOutput = actionQueryParser.parse({ + filter: advancedFilter.action, + orderBy: 'timestamp_DESC', + limit: 0, + offset: 0, + }) - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange - - if (start !== undefined && start !== null && start !== '') { - advancedFilter.and.push({ - createdAt: { - gte: start, - }, - }) - } - - if (end !== undefined && end !== null && end !== '') { - advancedFilter.and.push({ - createdAt: { - lte: end, - }, - }) - } - } + actionsSequelizeInclude.where = parsedActionQuery.where ?? {} + delete advancedFilter.action } - const parser = new QueryParser({}, options) + const include = [actionsSequelizeInclude] + + const contentParser = new QueryParser({}, options) - const parsed: QueryOutput = parser.parse({ + const parsed: QueryOutput = contentParser.parse({ filter: advancedFilter, - orderBy: orderBy || ['createdAt_DESC'], + orderBy: orderBy || ['postedAt_DESC'], limit, offset, }) - let { - rows, - count, // eslint-disable-line prefer-const - } = await options.database.eagleEyeContent.findAndCountAll({ + const hasActionFilter = Object.keys(actionsSequelizeInclude.where).length !== 0 + + let rows = await options.database.eagleEyeContent.findAll({ + include, ...(parsed.where ? { where: parsed.where } : {}), - ...(parsed.having ? { having: parsed.having } : {}), order: parsed.order, - limit: parsed.limit, - offset: parsed.offset, + limit: hasActionFilter ? null : parsed.limit, + offset: hasActionFilter ? 0 : parsed.offset, transaction: SequelizeRepository.getTransaction(options), + subQuery: true, + distinct: true, }) - rows = await this._populateRelationsForRows(rows) - - return { rows, count, limit: parsed.limit, offset: parsed.offset } - } - - static async update(id, data, options: IRepositoryOptions) { - const currentUser = SequelizeRepository.getCurrentUser(options) - - const transaction = SequelizeRepository.getTransaction(options) - - const currentTenant = SequelizeRepository.getCurrentTenant(options) - - let record = await options.database.eagleEyeContent.findOne({ - where: { - id, - tenantId: currentTenant.id, - }, - transaction, - }) - - if (!record) { - throw new Error404() - } - - if (data.status && ![null, 'rejected', 'engaged'].includes(data.status)) { - throw new Error400('en', 'errors.invalidEagleEyeStatus.message') + // count query will group by content id and create a response with action counts + // ie: it returns a payload similar to this + // [ contentId1: #ofActionsForContent1, contentId2: #ofActionsForContent2 ] + // To get the content count, we need to get the length of the response. + const count = ( + await options.database.eagleEyeContent.count({ + include, + ...(parsed.where ? { where: parsed.where } : {}), + transaction: SequelizeRepository.getTransaction(options), + distinct: true, + group: ['eagleEyeContent.id'], + }) + ).length + + // If we have an actions filter, we should query again to eager + // load the all actions on a content because previous query will + // omit actions that don't match the given action filter + if (hasActionFilter) { + rows = await options.database.eagleEyeContent.findAll({ + include: [ + { ...actionsSequelizeInclude, where: {}, limit: null, offset: 0, required: true }, + ], + where: { id: { [Op.in]: rows.map((i) => i.id) } }, + order: parsed.order, + transaction: SequelizeRepository.getTransaction(options), + subQuery: true, + limit: parsed.limit, + offset: parsed.offset, + distinct: true, + }) } - record = await record.update( - { - ...lodash.pick(data, [ - 'sourceId', - 'vectorId', - 'status', - 'title', - 'username', - 'url', - 'text', - 'postAttributes', - 'timestamp', - 'platform', - 'userAttributes', - 'importHash', - // Missing keywords on purpose - ]), - updatedById: currentUser.id, - }, - { - transaction, - }, - ) + rows = await this._populateRelationsForRows(rows) - await AuditLogRepository.log( - { - entityName: 'eagleEyeContent', - entityId: record.id, - action: AuditLogRepository.UPDATE, - values: data, - }, - options, - ) - return this.findById(record.id, options) + return { rows: rows ?? [], count, limit: parsed.limit, offset: parsed.offset } } - static async findById(id, options: IRepositoryOptions) { + static async findByUrl(url: string, options: IRepositoryOptions) { const transaction = SequelizeRepository.getTransaction(options) const include = [] @@ -378,7 +207,7 @@ export default class EagleEyeContentRepository { const record = await options.database.eagleEyeContent.findOne({ where: { - id, + url, tenantId: currentTenant.id, }, include, @@ -386,7 +215,7 @@ export default class EagleEyeContentRepository { }) if (!record) { - throw new Error404() + return null } return this._populateRelations(record) diff --git a/backend/src/database/repositories/weeklyAnalyticsEmailsHistoryRepository.ts b/backend/src/database/repositories/recurringEmailsHistoryRepository.ts similarity index 63% rename from backend/src/database/repositories/weeklyAnalyticsEmailsHistoryRepository.ts rename to backend/src/database/repositories/recurringEmailsHistoryRepository.ts index 92424d62cc..27ccba573b 100644 --- a/backend/src/database/repositories/weeklyAnalyticsEmailsHistoryRepository.ts +++ b/backend/src/database/repositories/recurringEmailsHistoryRepository.ts @@ -1,13 +1,16 @@ import { v4 as uuid } from 'uuid' import { QueryTypes } from 'sequelize' -import { WeeklyAnalyticsEmailsHistoryData } from '../../types/weeklyAnalyticsEmailsHistoryTypes' +import { + RecurringEmailsHistoryData, + RecurringEmailType, +} from '../../types/recurringEmailsHistoryTypes' import { IRepositoryOptions } from './IRepositoryOptions' import { RepositoryBase } from './repositoryBase' -class WeeklyAnalyticsEmailsHistoryRepository extends RepositoryBase< - WeeklyAnalyticsEmailsHistoryData, +class RecurringEmailsHistoryRepository extends RepositoryBase< + RecurringEmailsHistoryData, string, - WeeklyAnalyticsEmailsHistoryData, + RecurringEmailsHistoryData, unknown, unknown > { @@ -16,23 +19,24 @@ class WeeklyAnalyticsEmailsHistoryRepository extends RepositoryBase< } /** - * Inserts weekly analytics email history. - * @param data weekly emails historical data + * Inserts recurring emails receipt history. + * @param data recurring emails historical data * @param options * @returns */ - async create(data: WeeklyAnalyticsEmailsHistoryData): Promise { + async create(data: RecurringEmailsHistoryData): Promise { const historyInserted = await this.options.database.sequelize.query( - `INSERT INTO "weeklyAnalyticsEmailsHistory" ("id", "tenantId", "weekOfYear", "emailSentAt", "emailSentTo") + `INSERT INTO "recurringEmailsHistory" ("id", "type", "tenantId", "weekOfYear", "emailSentAt", "emailSentTo") VALUES - (:id, :tenantId, :weekOfYear, :emailSentAt, ARRAY[:emailSentTo]) + (:id, :type, :tenantId, :weekOfYear, :emailSentAt, ARRAY[:emailSentTo]) RETURNING "id" `, { replacements: { id: uuid(), + type: data.type, tenantId: data.tenantId, - weekOfYear: data.weekOfYear, + weekOfYear: data.weekOfYear || null, emailSentAt: data.emailSentAt, emailSentTo: data.emailSentTo, }, @@ -55,17 +59,20 @@ class WeeklyAnalyticsEmailsHistoryRepository extends RepositoryBase< async findByWeekOfYear( tenantId: string, weekOfYear: string, - ): Promise { + type: RecurringEmailType, + ): Promise { const records = await this.options.database.sequelize.query( `SELECT * - FROM "weeklyAnalyticsEmailsHistory" + FROM "recurringEmailsHistory" WHERE "tenantId" = :tenantId - AND "weekOfYear" = :weekOfYear; + AND "weekOfYear" = :weekOfYear + and "type" = :type; `, { replacements: { tenantId, weekOfYear, + type, }, type: QueryTypes.SELECT, }, @@ -85,10 +92,10 @@ class WeeklyAnalyticsEmailsHistoryRepository extends RepositoryBase< * @param options * @returns */ - async findById(id: string): Promise { + async findById(id: string): Promise { const records = await this.options.database.sequelize.query( `SELECT * - FROM "weeklyAnalyticsEmailsHistory" + FROM "recurringEmailsHistory" WHERE id = :id; `, { @@ -107,4 +114,4 @@ class WeeklyAnalyticsEmailsHistoryRepository extends RepositoryBase< } } -export default WeeklyAnalyticsEmailsHistoryRepository +export default RecurringEmailsHistoryRepository diff --git a/backend/src/database/repositories/userRepository.ts b/backend/src/database/repositories/userRepository.ts index 8b80aee5ed..7331bc3862 100644 --- a/backend/src/database/repositories/userRepository.ts +++ b/backend/src/database/repositories/userRepository.ts @@ -320,6 +320,37 @@ export default class UserRepository { return this.findById(user.id, options) } + static async updateEagleEyeSettings(id: string, data, options: IRepositoryOptions) { + const currentUser = SequelizeRepository.getCurrentUser(options) + const transaction = SequelizeRepository.getTransaction(options) + + const user = await options.database.user.findByPk(id, { + transaction, + }) + + await user.update( + { + eagleEyeSettings: { ...user.eagleEyeSettings, ...data }, + updatedById: currentUser.id, + }, + { transaction }, + ) + + await AuditLogRepository.log( + { + entityName: 'user', + entityId: user.id, + action: AuditLogRepository.UPDATE, + values: { + ...user.get({ plain: true }), + eagleEyeSettings: data, + }, + }, + options, + ) + return this.findById(user.id, options) + } + static async findByEmail(email, options: IRepositoryOptions) { const transaction = SequelizeRepository.getTransaction(options) diff --git a/backend/src/database/utils/getUserContext.ts b/backend/src/database/utils/getUserContext.ts index f818b8a2d0..4a1dbf9bc0 100644 --- a/backend/src/database/utils/getUserContext.ts +++ b/backend/src/database/utils/getUserContext.ts @@ -9,17 +9,25 @@ import TenantRepository from '../repositories/tenantRepository' * @param tenantId * @returns IRepositoryOptions injected with currentTenant and currentUser */ -export default async function getUserContext(tenantId: string): Promise { +export default async function getUserContext( + tenantId: string, + userId?: string, +): Promise { const options = await SequelizeRepository.getDefaultIRepositoryOptions() const tenant = await TenantRepository.findById(tenantId, { ...options, }) let user = null - const tenantUsers = await tenant.getUsers() - if (tenantUsers.length > 0) { - user = await tenantUsers[0].getUser() + if (userId) { + user = await options.database.user.findByPk(userId) + } else { + const tenantUsers = await tenant.getUsers() + + if (tenantUsers.length > 0) { + user = await tenantUsers[0].getUser() + } } // Inject user and tenant to IRepositoryOptions diff --git a/backend/src/database/utils/sequelizeTestUtils.ts b/backend/src/database/utils/sequelizeTestUtils.ts index 7b103981f4..83e813d9a2 100644 --- a/backend/src/database/utils/sequelizeTestUtils.ts +++ b/backend/src/database/utils/sequelizeTestUtils.ts @@ -34,6 +34,7 @@ export default class SequelizeTestUtils { files, microservices, "eagleEyeContents", + "eagleEyeActions", "auditLogs", "memberEnrichmentCache" cascade; diff --git a/backend/src/i18n/en.ts b/backend/src/i18n/en.ts index 0a41b33134..5b802032de 100644 --- a/backend/src/i18n/en.ts +++ b/backend/src/i18n/en.ts @@ -102,19 +102,30 @@ const en = { activityDup: { message: 'This activity has already been linked to this member', }, - invalidEagleEyeStatus: { - message: 'Possible statuses are: "shown", "rejected", "engaged"', - }, - eagleEyeSearchFailed: { - message: 'Search failed in EagleEye', - }, OrganizationNameRequired: { message: 'Organization Name is required', }, projectNotFound: { message: 'Project not found', }, - + eagleEye: { + urlRequiredWhenUpserting: 'URL field is mandatory when upserting eagleEyeContent', + contentNotFound: 'Eagle eye content not found. Action will not be created.', + feedSettingsMissing: 'Feed settings are missing. Settings not updated.', + keywordsMissing: + 'Either keywords or exactKeywords are required in feeds. Settings not updated.', + platformMissing: + 'feed.platforms is required and must be a non-empty list. Settings not updated.', + platformInvalid: `feed.platforms contains {0}, which is not in [{1}]. Settings not updated.`, + publishedDateMissing: + 'feed.publishedDate is missing or invalid. It should be one of [{0}]. Settings not updated.', + emailInvalid: 'emailDigest.email needs a valid email address. Settings not updated.', + frequencyInvalid: + 'emailDigest.frequency needs to be one of daily, weekly. Settings not updated.', + timeInvalid: 'emailDigest.time needs to be a valid time. Settings not updated.', + notOnboarded: 'Eagle eye is not set up yet.', + invalidEvent: 'Invalid event type.', + }, integrations: { badEndpoint: 'Bad endpoint: {0}', }, @@ -195,6 +206,12 @@ const en = { planLimitExceeded: 'You have exceeded # of automations you can have in your plan.', }, }, + eagleEye: { + errors: { + planLimitExceeded: + 'EagleEye is only available in the Growth and Custom plans. Please upgrade your plan.', + }, + }, }, communityHelpCenter: { diff --git a/backend/src/security/permissions.ts b/backend/src/security/permissions.ts index 91b91fdd82..17b245ad42 100644 --- a/backend/src/security/permissions.ts +++ b/backend/src/security/permissions.ts @@ -422,20 +422,35 @@ class Permissions { allowedRoles: [roles.admin, roles.readonly], allowedPlans: [plans.essential, plans.growth], }, + eagleEyeActionCreate: { + id: 'eagleEyeActionCreate', + allowedRoles: [roles.admin], + allowedPlans: [plans.growth, plans.essential], + }, + eagleEyeActionDestroy: { + id: 'eagleEyeActionDestroy', + allowedRoles: [roles.admin], + allowedPlans: [plans.growth, plans.essential], + }, + eagleEyeContentCreate: { + id: 'eagleEyeContentCreate', + allowedRoles: [roles.admin], + allowedPlans: [plans.growth, plans.essential], + }, eagleEyeContentRead: { id: 'eagleEyeContentRead', allowedRoles: [roles.admin, roles.readonly], - allowedPlans: [plans.growth], + allowedPlans: [plans.growth, plans.essential], }, eagleEyeContentSearch: { id: 'eagleEyeContentSearch', allowedRoles: [roles.admin], - allowedPlans: [plans.growth], + allowedPlans: [plans.growth, plans.essential], }, eagleEyeContentEdit: { id: 'eagleEyeContentEdit', allowedRoles: [roles.admin], - allowedPlans: [plans.growth], + allowedPlans: [plans.growth, plans.essential], }, taskImport: { id: 'taskImport', diff --git a/backend/src/serverless/integrations/services/integrations/hackerNewsIntegrationService.ts b/backend/src/serverless/integrations/services/integrations/hackerNewsIntegrationService.ts index 5c43180af6..486683a1bf 100644 --- a/backend/src/serverless/integrations/services/integrations/hackerNewsIntegrationService.ts +++ b/backend/src/serverless/integrations/services/integrations/hackerNewsIntegrationService.ts @@ -1,4 +1,5 @@ import sanitizeHtml from 'sanitize-html' +import moment from 'moment' import { MemberAttributeName } from '../../../../database/attributes/member/enums' import { HackerNewsMemberAttributes } from '../../../../database/attributes/member/hackerNews' import MemberAttributeSettingsService from '../../../../services/memberAttributeSettingsService' @@ -13,9 +14,9 @@ import Operations from '../../../dbOperations/operations' import getPost from '../../usecases/hackerNews/getPost' import { HackerNewsGrid } from '../../grid/hackerNewsGrid' import { - EagleEyeResponse, HackerNewsResponse, HackerNewsIntegrationSettings, + HackerNewsSearchResult, } from '../../types/hackerNewsTypes' import { AddActivitiesSingle } from '../../types/messageTypes' import getPostsByKeywords from '../../usecases/hackerNews/getPostsByKeywords' @@ -43,7 +44,10 @@ export class HackerNewsIntegrationService extends IntegrationServiceBase { const keywords = Array.from(new Set([...settings.keywords, ...settings.urls])) context.logger.info(`Fetching posts for keywords: ${keywords}`) const posts = await getPostsByKeywords( - { keywords, nDays: context.onboarding ? 1000000 : 3 }, + { + keywords, + after: context.onboarding ? 0 : moment().subtract(30, 'days').unix(), + }, context.serviceContext, context.logger, ) @@ -55,8 +59,8 @@ export class HackerNewsIntegrationService extends IntegrationServiceBase { } async getStreams(context: IStepContext): Promise { - return context.pipelineData.posts.map((a: EagleEyeResponse) => ({ - value: a.sourceId.slice(a.sourceId.lastIndexOf(':') + 1), + return context.pipelineData.posts.map((a: HackerNewsSearchResult) => ({ + value: a.postId, metadata: { channel: a.keywords[0], }, diff --git a/backend/src/serverless/integrations/types/hackerNewsTypes.ts b/backend/src/serverless/integrations/types/hackerNewsTypes.ts index 89bc808f3a..5093679316 100644 --- a/backend/src/serverless/integrations/types/hackerNewsTypes.ts +++ b/backend/src/serverless/integrations/types/hackerNewsTypes.ts @@ -1,22 +1,37 @@ -export interface EagleEyeResponse { - vectorId: number - sourceId: string - title: string - url: string - createdAt: string - text: string - username: string - platform: string - timestamp: string - userAttributes: any - postAttributes: { - commentsCount: number - score: number - } - keywords: string[] +export interface HackerNewsSearchResponseRaw { + hits: [ + { + created_at: string + title: string + url: string + author: string + points: number + story_text: string + comment_text: string | null + num_comments: number + story_id: string | null + story_title: string | null + story_url: string | null + parent_id: string | null + created_at_i: number + _tags: string[] + objectID: string + _highlightResult: { + [key: string]: { + value: string + matchLevel: string + matchedWords: string[] + fullyHighlighted?: boolean + } + } + }, + ] } -export type EagleEyeResponses = EagleEyeResponse[] +export interface HackerNewsSearchResult { + keywords: string[] + postId: number +} export interface HackerNewsPost { by: string @@ -49,7 +64,7 @@ export interface HackerNewsIntegrationSettings { urls: string[] } -export interface EagleEyeInput { +export interface HackerNewsKeywordSearchInput { keywords: string[] - nDays: number + after: number } diff --git a/backend/src/serverless/integrations/usecases/hackerNews/getPostsByKeywords.ts b/backend/src/serverless/integrations/usecases/hackerNews/getPostsByKeywords.ts index 0e19a3ac5e..6a3563b131 100644 --- a/backend/src/serverless/integrations/usecases/hackerNews/getPostsByKeywords.ts +++ b/backend/src/serverless/integrations/usecases/hackerNews/getPostsByKeywords.ts @@ -1,23 +1,50 @@ +import axios from 'axios' import { IServiceOptions } from '../../../../services/IServiceOptions' -import { EagleEyeResponses, EagleEyeInput } from '../../types/hackerNewsTypes' +import { + HackerNewsKeywordSearchInput, + HackerNewsSearchResponseRaw, + HackerNewsSearchResult, +} from '../../types/hackerNewsTypes' import { Logger } from '../../../../utils/logging' import { timeout } from '../../../../utils/timing' -import EagleEyeContentService from '../../../../services/eagleEyeContentService' async function getPostsByKeyword( - input: EagleEyeInput, + input: HackerNewsKeywordSearchInput, options: IServiceOptions, logger: Logger, -): Promise { +): Promise { await timeout(2000) try { - const eagleEyeService = new EagleEyeContentService(options) - return await eagleEyeService.keywordMatch({ - keywords: input.keywords, - nDays: input.nDays, - platform: 'hacker_news', - }) + const out = [] + const existing = new Set() + for (const keyword of input.keywords) { + const config = { + method: 'get', + maxBodyLength: Infinity, + url: 'http://hn.algolia.com/api/v1/search', + params: { + query: keyword, + numericFilters: `created_at_i>${input.after}`, + tags: '(story,ask_hn,show_hn,poll)', + }, + headers: {}, + } + + const response = await axios(config) + const data = response.data as HackerNewsSearchResponseRaw + + for (const item of data.hits) { + if (!existing.has(item.objectID)) { + out.push({ + keywords: [keyword], + postId: item.objectID, + }) + existing.add(item.objectID) + } + } + } + return out } catch (err) { logger.error({ err, input }, 'Error while getting posts by keyword in EagleEye') throw err diff --git a/backend/src/serverless/microservices/nodejs/analytics/workers/weeklyAnalyticsEmailsWorker.ts b/backend/src/serverless/microservices/nodejs/analytics/workers/weeklyAnalyticsEmailsWorker.ts index a9fa79f68e..fdc8d9ea05 100644 --- a/backend/src/serverless/microservices/nodejs/analytics/workers/weeklyAnalyticsEmailsWorker.ts +++ b/backend/src/serverless/microservices/nodejs/analytics/workers/weeklyAnalyticsEmailsWorker.ts @@ -14,10 +14,11 @@ import { createServiceChildLogger } from '../../../../../utils/logging' import { prettyActivityTypes } from '../../../../../types/prettyActivityTypes' import ConversationRepository from '../../../../../database/repositories/conversationRepository' import { PlatformType } from '../../../../../types/integrationEnums' -import WeeklyAnalyticsEmailsHistoryRepository from '../../../../../database/repositories/weeklyAnalyticsEmailsHistoryRepository' +import RecurringEmailsHistoryRepository from '../../../../../database/repositories/recurringEmailsHistoryRepository' import { sendNodeWorkerMessage } from '../../../../utils/nodeWorkerSQS' import { NodeWorkerMessageType } from '../../../../types/workerTypes' import { NodeWorkerMessageBase } from '../../../../../types/mq/nodeWorkerMessageBase' +import { RecurringEmailType } from '../../../../../types/recurringEmailsHistoryTypes' const log = createServiceChildLogger('weeklyAnalyticsEmailsWorker') @@ -83,7 +84,7 @@ async function weeklyAnalyticsEmailsWorker(tenantId: string): Promise 0) { log.info(tenantId, ` has completed integrations. Eligible for weekly emails.. `) @@ -171,14 +172,15 @@ async function weeklyAnalyticsEmailsWorker(tenantId: string): Promise { + const s3Url = `https://${ + S3_CONFIG.microservicesAssetsBucket + }-${getStage()}.s3.eu-central-1.amazonaws.com` + const options = await SequelizeRepository.getDefaultIRepositoryOptions() + const user = await UserRepository.findById(userId, { + ...options, + bypassPermissionValidation: true, + }) + + if (moment(user.eagleEyeSettings.emailDigest.nextEmailAt) > moment()) { + log.info( + 'nextEmailAt is already updated. Email is already sent. Exiting without sending the email.', + ) + return + } + + const userContext = await getUserContext(user.tenants[0].tenant.id, user.id) + + const eagleEyeContentService = new EagleEyeContentService(userContext) + const content = (await eagleEyeContentService.search(true)).slice(0, 10).map((c: any) => { + c.platformIcon = `${s3Url}/email/${c.platform}.png` + c.post.thumbnail = null + return c + }) + + await new EmailSender(EmailSender.TEMPLATES.EAGLE_EYE_DIGEST, { + content, + frequency: user.eagleEyeSettings.emailDigest.frequency, + date: moment().format('D MMM YYYY'), + }).sendTo(user.eagleEyeSettings.emailDigest.email) + + const rehRepository = new RecurringEmailsHistoryRepository(userContext) + + const reHistory = await rehRepository.create({ + tenantId: userContext.currentTenant.id, + type: RecurringEmailType.EAGLE_EYE_DIGEST, + emailSentAt: moment().toISOString(), + emailSentTo: [user.eagleEyeSettings.emailDigest.email], + }) + + // update nextEmailAt + const nextEmailAt = EagleEyeSettingsService.getNextEmailDigestDate( + user.eagleEyeSettings.emailDigest, + ) + const updateSettings = user.eagleEyeSettings + updateSettings.emailDigest.nextEmailAt = nextEmailAt + + await UserRepository.updateEagleEyeSettings( + userContext.currentUser.id, + updateSettings, + userContext, + ) + + log.info({ receipt: reHistory }) +} + +export { eagleEyeEmailDigestWorker } diff --git a/backend/src/serverless/microservices/nodejs/messageTypes.ts b/backend/src/serverless/microservices/nodejs/messageTypes.ts index 3b25488ae0..19d9e7c04f 100644 --- a/backend/src/serverless/microservices/nodejs/messageTypes.ts +++ b/backend/src/serverless/microservices/nodejs/messageTypes.ts @@ -15,6 +15,10 @@ export type CsvExportMessage = BaseNodeMicroserviceMessage & { criteria: any } +export type EagleEyeEmailDigestMessage = BaseNodeMicroserviceMessage & { + user: string +} + export type ActivityAutomationData = { activityId?: string activity?: any diff --git a/backend/src/serverless/microservices/nodejs/workerFactory.ts b/backend/src/serverless/microservices/nodejs/workerFactory.ts index ff4462b4d1..1fb11b0bd5 100644 --- a/backend/src/serverless/microservices/nodejs/workerFactory.ts +++ b/backend/src/serverless/microservices/nodejs/workerFactory.ts @@ -9,6 +9,7 @@ import { ProcessAutomationMessage, ProcessWebhookAutomationMessage, BulkEnrichMessage, + EagleEyeEmailDigestMessage, } from './messageTypes' import { AutomationTrigger, AutomationType } from '../../../types/automationTypes' import newActivityWorker from './automation/workers/newActivityWorker' @@ -17,6 +18,7 @@ import webhookWorker from './automation/workers/webhookWorker' import { csvExportWorker } from './csv-export/csvExportWorker' import { processWebhook } from '../../integrations/workers/stripeWebhookWorker' import { bulkEnrichmentWorker } from './bulk-enrichment/bulkEnrichmentWorker' +import { eagleEyeEmailDigestWorker } from './eagle-eye-email-digest/eagleEyeEmailDigestWorker' /** * Worker factory for spawning different microservices @@ -33,6 +35,9 @@ async function workerFactory(event: NodeMicroserviceMessage): Promise { return processWebhook(event) case 'weekly-analytics-emails': return weeklyAnalyticsEmailsWorker(tenant) + case 'eagle-eye-email-digest': + const eagleEyeDigestMessage = event as EagleEyeEmailDigestMessage + return eagleEyeEmailDigestWorker(eagleEyeDigestMessage.user) case 'csv-export': const csvExportMessage = event as CsvExportMessage return csvExportWorker( diff --git a/backend/src/services/__tests__/eagleEyeContentService.test.ts b/backend/src/services/__tests__/eagleEyeContentService.test.ts index 46b7d51a8b..9170b4a645 100644 --- a/backend/src/services/__tests__/eagleEyeContentService.test.ts +++ b/backend/src/services/__tests__/eagleEyeContentService.test.ts @@ -1,44 +1,9 @@ -import moment from 'moment' import SequelizeTestUtils from '../../database/utils/sequelizeTestUtils' -import { PlatformType } from '../../types/integrationEnums' +import { EagleEyeActionType, EagleEyeContent } from '../../types/eagleEyeTypes' import EagleEyeContentService from '../eagleEyeContentService' const db = null -const toUpsert = { - keywords: ['keyword'], - similarityScore: 1, - userAttributes: { - [PlatformType.GITHUB]: 'github', - }, - username: 'username', - timestamp: moment().unix(), - url: 'url', - title: 'title', - text: 'text', - platform: 'platform', - sourceId: 'sourceId', - vectorId: '1234', - status: null, -} - -const toUpsert2 = { - keywords: ['keyword'], - similarityScore: 1, - userAttributes: { - [PlatformType.GITHUB]: 'github', - }, - username: 'username', - timestamp: moment().subtract(1, 'days').unix(), - url: 'url', - title: 'title', - text: 'text', - platform: 'platform', - sourceId: 'sourceId2', - vectorId: '12345', - status: null, -} - describe('EagleEyeContentService tests', () => { beforeEach(async () => { await SequelizeTestUtils.wipeDatabase(db) @@ -49,91 +14,54 @@ describe('EagleEyeContentService tests', () => { await SequelizeTestUtils.closeConnection(db) }) - describe('bulk upsert method', () => { - it('Should upsert a single record', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const service = new EagleEyeContentService(mockIRepositoryOptions) - await service.bulkUpsert([toUpsert]) - const result = await service.findAndCountAll({}) - expect(result.count).toBe(1) - }) - - it('Should upsert a single record', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const service = new EagleEyeContentService(mockIRepositoryOptions) - await service.bulkUpsert([toUpsert]) - await service.bulkUpsert([toUpsert2]) - const result = await service.findAndCountAll({}) - expect(result.count).toBe(2) - }) - }) - - describe('findAndCount all method', () => { - it('Should find records', async () => { + describe('upsert method', () => { + it('Should create or update a single content using URL field', async () => { const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const service = new EagleEyeContentService(mockIRepositoryOptions) - await service.bulkUpsert([toUpsert]) - await service.bulkUpsert([toUpsert2]) - const result = await service.findAndCountAll({}) - expect(result.count).toBe(2) - expect(result.rows[1].vectorId).toBe(toUpsert.vectorId) - expect(result.rows[0].vectorId).toBe(toUpsert2.vectorId) - }) + const content: EagleEyeContent = { + platform: 'reddit', + url: 'https://some-post-url', + post: { + title: 'post title', + body: 'post body', + }, + postedAt: '2020-05-27T15:13:30Z', + tenantId: mockIRepositoryOptions.currentTenant.id, + actions: [ + { + type: EagleEyeActionType.BOOKMARK, + timestamp: '2022-06-27T14:13:30Z', + }, + ], + } - it('Should work when no records', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) const service = new EagleEyeContentService(mockIRepositoryOptions) - const result = await service.findAndCountAll({}) - expect(result.count).toBe(0) - }) - }) + const c1 = await service.upsert(content) - describe('findNotInbox method', () => { - it('4 records: 2 have status null, one is too old. Return 1', async () => { - const mockIRepositoryOptions = await SequelizeTestUtils.getTestIRepositoryOptions(db) - const service = new EagleEyeContentService(mockIRepositoryOptions) + let contents = await service.query({}) - const nInbox1 = { - keywords: ['keyword'], - similarityScore: 1, - userAttributes: { - [PlatformType.GITHUB]: 'github', - }, - username: 'username', - timestamp: moment().unix(), - url: 'url', - title: 'title', - text: 'text', - platform: 'platform', - sourceId: 'p-321', - vectorId: '321', - status: 'rejected', - } + expect(contents.count).toBe(1) + expect(contents.rows).toStrictEqual([c1]) - const nInbox2 = { - keywords: ['keyword'], - similarityScore: 1, - userAttributes: { - [PlatformType.GITHUB]: 'github', + // upsert previous url with some new fields + const contentWithSameUrl: EagleEyeContent = { + platform: 'reddit', + url: 'https://some-post-url', + post: { + title: 'a brand new post title', + body: 'better post body', }, - username: 'username', - timestamp: moment().subtract(31, 'days').unix(), - url: 'url', - title: 'title', - text: 'text', - platform: 'platform', - sourceId: 'p-4321', - vectorId: '4321', - status: 'engaged', + postedAt: '2020-05-27T15:13:30Z', + tenantId: mockIRepositoryOptions.currentTenant.id, } - await service.bulkUpsert([toUpsert, nInbox1, nInbox2, toUpsert2]) - - const result = await service.findNotInbox() + const c1Upserted = await service.upsert(contentWithSameUrl) - expect(result.length).toBe(1) - expect(result[0]).toBe(nInbox1.vectorId) + contents = await service.query({}) + expect(contents.count).toBe(1) + expect(contents.rows).toStrictEqual([c1Upserted]) + expect(c1Upserted.id).toEqual(c1.id) + expect(contents.rows[0].post).toStrictEqual(contentWithSameUrl.post) }) }) }) diff --git a/backend/src/services/eagleEyeActionService.ts b/backend/src/services/eagleEyeActionService.ts new file mode 100644 index 0000000000..79a5bbd956 --- /dev/null +++ b/backend/src/services/eagleEyeActionService.ts @@ -0,0 +1,130 @@ +import EagleEyeActionRepository from '../database/repositories/eagleEyeActionRepository' +import EagleEyeContentRepository from '../database/repositories/eagleEyeContentRepository' +import SequelizeRepository from '../database/repositories/sequelizeRepository' +import Error404 from '../errors/Error404' +import { EagleEyeAction, EagleEyeActionType } from '../types/eagleEyeTypes' +import { IServiceOptions } from './IServiceOptions' +import { LoggingBase } from './loggingBase' +import track from '../segment/track' + +export default class EagleEyeActionService extends LoggingBase { + options: IServiceOptions + + constructor(options) { + super(options) + this.options = options + } + + async create(data: EagleEyeAction, contentId: string): Promise { + const transaction = await SequelizeRepository.createTransaction(this.options) + + // find content + const content = await EagleEyeContentRepository.findById(contentId, { + ...this.options, + transaction, + }) + + if (!content) { + throw new Error404(this.options.language, 'errors.eagleEye.contentNotFound') + } + + // Tracking here so we have access to url and platform + track( + `Eagle Eye post ${data.type === EagleEyeActionType.BOOKMARK ? 'bookmarked' : 'voted'}`, + { + type: data.type, + url: content.url, + platform: content.platform, + action: 'create', + }, + { ...this.options }, + ) + + const existingUserActions: EagleEyeAction[] = content.actions.filter( + (a) => a.actionById === this.options.currentUser.id, + ) + + const existingUserActionTypes = existingUserActions.map((a) => a.type) + + try { + // check if already bookmarked - if yes ignore the new action and return existing + if ( + data.type === EagleEyeActionType.BOOKMARK && + existingUserActionTypes.includes(EagleEyeActionType.BOOKMARK) + ) { + return existingUserActions.find((a) => a.type === EagleEyeActionType.BOOKMARK) + } + + // thumbs up and down should be mutually exclusive + if ( + data.type === EagleEyeActionType.THUMBS_DOWN && + existingUserActionTypes.includes(EagleEyeActionType.THUMBS_UP) + ) { + await EagleEyeActionRepository.removeActionFromContent( + EagleEyeActionType.THUMBS_UP, + contentId, + { + ...this.options, + transaction, + }, + ) + } else if ( + data.type === EagleEyeActionType.THUMBS_UP && + existingUserActionTypes.includes(EagleEyeActionType.THUMBS_DOWN) + ) { + await EagleEyeActionRepository.removeActionFromContent( + EagleEyeActionType.THUMBS_DOWN, + contentId, + { + ...this.options, + transaction, + }, + ) + } + + // add new action + const record = await EagleEyeActionRepository.createActionForContent(data, contentId, { + ...this.options, + transaction, + }) + + await SequelizeRepository.commitTransaction(transaction) + + return record + } catch (error) { + await SequelizeRepository.rollbackTransaction(transaction) + + SequelizeRepository.handleUniqueFieldError(error, this.options.language, 'EagleEyeContent') + + throw error + } + } + + async destroy(id: string) { + const action = await EagleEyeActionRepository.findById(id, this.options) + + const contentId = action.contentId + + await EagleEyeActionRepository.destroy(id, this.options) + + // find content + const content = await EagleEyeContentRepository.findById(contentId, this.options) + + // if content no longer has any actions attached to it, also delete the content + if (content.actions.length === 0) { + await EagleEyeContentRepository.destroy(contentId, this.options) + } + + // Tracking here so we have access to url and platform + track( + `Eagle Eye post ${action.type === EagleEyeActionType.BOOKMARK ? 'bookmarked' : 'voted'}`, + { + type: action.type, + url: content.url, + platform: content.platform, + action: 'destroy', + }, + { ...this.options }, + ) + } +} diff --git a/backend/src/services/eagleEyeContentService.ts b/backend/src/services/eagleEyeContentService.ts index e42de0de9a..412e601e49 100644 --- a/backend/src/services/eagleEyeContentService.ts +++ b/backend/src/services/eagleEyeContentService.ts @@ -1,30 +1,26 @@ import moment from 'moment' -import request from 'superagent' -import { API_CONFIG } from '../config' -import SequelizeRepository from '../database/repositories/sequelizeRepository' +import axios from 'axios' +import { EAGLE_EYE_CONFIG } from '../config' import { IServiceOptions } from './IServiceOptions' import EagleEyeContentRepository from '../database/repositories/eagleEyeContentRepository' -import Error400 from '../errors/Error400' -import track from '../segment/track' import { LoggingBase } from './loggingBase' +import { + EagleEyeContent, + EagleEyeAction, + EagleEyeSettings, + EagleEyePublishedDates, + EagleEyeRawPost, + EagleEyePostWithActions, +} from '../types/eagleEyeTypes' +import { PageData, QueryData } from '../types/common' +import Error400 from '../errors/Error400' +import UserRepository from '../database/repositories/userRepository' +import SequelizeRepository from '../database/repositories/sequelizeRepository' -interface EagleEyeSearchPoint { - vectorId: string - sourceId: string - title: string - text?: string - url: string - timestamp: number - username: string - similarityScore: number - userAttributes: { - [platform: string]: string - } - keywords: string[] +export interface EagleEyeContentUpsertData extends EagleEyeAction { + content: EagleEyeContent } -type EagleEyeSearchOutput = EagleEyeSearchPoint[] - export default class EagleEyeContentService extends LoggingBase { options: IServiceOptions @@ -33,51 +29,53 @@ export default class EagleEyeContentService extends LoggingBase { this.options = options } - async upsert(data) { + /** + * Create an eagle eye shown content record. + * @param data Data to a new EagleEyeContent record. + * @param options Repository options. + * @returns Created EagleEyeContent record. + */ + async upsert(data: EagleEyeContent): Promise { + if (!data.url) { + throw new Error400(this.options.language, 'errors.eagleEye.urlRequiredWhenUpserting') + } const transaction = await SequelizeRepository.createTransaction(this.options) try { - const record = await EagleEyeContentRepository.upsert(data, { + // find by url + const existing = await EagleEyeContentRepository.findByUrl(data.url, { ...this.options, transaction, }) + let record + + if (existing) { + record = await EagleEyeContentRepository.update(existing.id, data, { + ...this.options, + transaction, + }) + } else { + record = await EagleEyeContentRepository.create(data, { + ...this.options, + transaction, + }) + } + await SequelizeRepository.commitTransaction(transaction) return record } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) - - SequelizeRepository.handleUniqueFieldError(error, this.options.language, 'EagleEyeContent') - throw error } } - async findNotInbox() { - const shown = ( - await EagleEyeContentRepository.findAndCountAll( - { - filter: { - timestampRange: [ - moment().subtract(30, 'days').toDate(), - moment().add(1, 'hour').toDate(), - ], - status: 'NOT_NULL', - }, - }, - this.options, - ) - ).rows - // Slicing results such that lambda payload will not be too big - return shown.map((record) => record.vectorId).slice(0, 20000) - } - - async findAndCountAll(args) { - return EagleEyeContentRepository.findAndCountAll(args, this.options) + async findById(id: string): Promise { + return EagleEyeContentRepository.findById(id, this.options) } - async query(data) { + async query(data: QueryData): Promise> { const advancedFilter = data.filter const orderBy = data.orderBy const limit = data.limit @@ -88,99 +86,122 @@ export default class EagleEyeContentService extends LoggingBase { ) } - async bulkUpsert(data: EagleEyeSearchOutput) { - for (const point of data) { - await this.upsert(point) + /** + * Convert a relative string date to a Date. For example, 30 days ago -> 2020-01-01 + * @param date String date. Can be one of EagleEyePublishedDates + * @returns The corresponding Date + */ + static switchDate(date: string, offset = 0) { + let dateMoment + switch (date) { + case EagleEyePublishedDates.LAST_24_HOURS: + dateMoment = moment().subtract(1, 'days') + break + case EagleEyePublishedDates.LAST_7_DAYS: + dateMoment = moment().subtract(7, 'days') + break + case EagleEyePublishedDates.LAST_14_DAYS: + dateMoment = moment().subtract(14, 'days') + break + case EagleEyePublishedDates.LAST_30_DAYS: + dateMoment = moment().subtract(30, 'days') + break + case EagleEyePublishedDates.LAST_90_DAYS: + dateMoment = moment().subtract(90, 'days') + break + default: + return null } + return dateMoment.subtract(offset, 'days').format('YYYY-MM-DD') } - async search(args) { - const { keywords, nDays, exactKeywords } = args - // We do not want what we have already accepted or rejected - const filters = await this.findNotInbox() - if (API_CONFIG.premiumApiUrl) { - const response = await request - .post(`${API_CONFIG.premiumApiUrl}/search`) - .send({ queries: keywords, nDays, filters, exactKeywords }) - const fromEagleEye: EagleEyeSearchOutput = JSON.parse(response.text) - await this.bulkUpsert(fromEagleEye) - return fromEagleEye + async search(email = false) { + const eagleEyeSettings: EagleEyeSettings = ( + await UserRepository.findById(this.options.currentUser.id, this.options) + ).eagleEyeSettings + + if (!eagleEyeSettings.onboarded) { + throw new Error400(this.options.language, 'errors.eagleEye.notOnboarded') } - return [] as EagleEyeSearchOutput - } - async keywordMatch(args) { - const { keywords, nDays, platform } = args - - if (API_CONFIG.premiumApiUrl) { - const response = await request - .post(`${API_CONFIG.premiumApiUrl}/keyword-match`) - .send({ exactKeywords: keywords, nDays, platform }) - try { - return JSON.parse(response.text) - } catch (error) { - this.log.error({ error: response.error }, 'error while calling eagle eye server!') - throw new Error400('en', 'errors.eagleEyeSearchFailed.message') - } - } else { - return [] as EagleEyeSearchOutput + const feedSettings = email ? eagleEyeSettings.emailDigest.feed : eagleEyeSettings.feed + + const keywords = feedSettings.keywords ? feedSettings.keywords.join(',') : '' + const exactKeywords = feedSettings.exactKeywords ? feedSettings.exactKeywords.join(',') : '' + const excludedKeywords = feedSettings.excludedKeywords + ? feedSettings.excludedKeywords.join(',') + : '' + + const afterDate = EagleEyeContentService.switchDate(feedSettings.publishedDate) + + const config = { + method: 'get', + maxBodyLength: Infinity, + url: `${EAGLE_EYE_CONFIG.url}`, + params: { + platforms: feedSettings.platforms.join(','), + keywords, + exact_keywords: exactKeywords, + exclude_keywords: excludedKeywords, + after_date: afterDate, + }, + headers: { + Authorization: `Bearer ${EAGLE_EYE_CONFIG.apiKey}`, + }, } - } - async update(id, data) { - const transaction = await SequelizeRepository.createTransaction(this.options) + const response = await axios(config) - try { - const recordBeforeUpdate = await EagleEyeContentRepository.findById(id, { ...this.options }) - const record = await EagleEyeContentRepository.update(id, data, { - ...this.options, - transaction, + const interacted = ( + await this.query({ + filter: { + postedAt: { gt: EagleEyeContentService.switchDate(feedSettings.publishedDate, 90) }, + }, }) + ).rows - // If we are updating status we want to track it - if (data.status !== recordBeforeUpdate.status) { - // If we are going from null to status, we are either accepting or rejecting - if (data.status && data.status !== null && data.status !== undefined) { - track( - `EagleEye ${data.status}`, - { - ...data, - platform: record.platform, - keywords: record.keywords, - title: record.title, - url: record.url, - }, - { ...this.options }, - ) - // Here we are bringing back a rejected post to the Inbox - } else if (recordBeforeUpdate.status === 'rejected' && data.status === null) { - track( - `EagleEye post from rejected to Inbox`, - { - ...data, - platform: record.platform, - keywords: record.keywords, - title: record.title, - url: record.url, - }, - { ...this.options }, - ) - } - } + const interactedMap = {} - await SequelizeRepository.commitTransaction(transaction) + for (const item of interacted) { + interactedMap[item.url] = item + } - return record - } catch (error) { - await SequelizeRepository.rollbackTransaction(transaction) + const out: EagleEyePostWithActions[] = [] + for (const item of response.data as EagleEyeRawPost[]) { + const post = { + description: item.description, + thumbnail: item.thumbnail, + title: item.title, + } + out.push({ + url: item.url, + postedAt: item.date, + post, + platform: item.platform, + actions: interactedMap[item.url] ? interactedMap[item.url].actions : [], + }) + } - SequelizeRepository.handleUniqueFieldError(error, this.options.language, 'EagleEyeContent') + return out + } - throw error + static async reply(title, description) { + const config = { + method: 'get', + maxBodyLength: Infinity, + url: `${EAGLE_EYE_CONFIG.url}/reply`, + params: { + title, + description, + }, + headers: { + Authorization: `Bearer ${EAGLE_EYE_CONFIG.apiKey}`, + }, } - } - async findById(id) { - return EagleEyeContentRepository.findById(id, this.options) + const response = await axios(config) + return { + reply: response.data, + } } } diff --git a/backend/src/services/eagleEyeSettingsService.ts b/backend/src/services/eagleEyeSettingsService.ts new file mode 100644 index 0000000000..f8d2bb9b77 --- /dev/null +++ b/backend/src/services/eagleEyeSettingsService.ts @@ -0,0 +1,252 @@ +import lodash from 'lodash' +import moment from 'moment' +import SequelizeRepository from '../database/repositories/sequelizeRepository' +import UserRepository from '../database/repositories/userRepository' +import Error400 from '../errors/Error400' +import track from '../segment/track' +import { + EagleEyeSettings, + EagleEyeFeedSettings, + EagleEyePlatforms, + EagleEyePublishedDates, + EagleEyeEmailDigestSettings, + EagleEyeEmailDigestFrequency, +} from '../types/eagleEyeTypes' +import { IServiceOptions } from './IServiceOptions' +import { LoggingBase } from './loggingBase' + +/* eslint-disable no-case-declarations */ + +export default class EagleEyeSettingsService extends LoggingBase { + options: IServiceOptions + + constructor(options) { + super(options) + this.options = options + } + + /** + * Validate and normalize feed settings. + * @param data Feed data of type EagleEyeFeedSettings + * @returns Normalized feed data if the input is valid. Otherwise a 400 Error + */ + getFeed(data: EagleEyeFeedSettings) { + // Feed is compulsory + if (!data) { + throw new Error400(this.options.language, 'errors.eagleEye.feedSettingsMissing') + } + + // We need at least one of keywords or exactKeywords + if (!data.keywords && !data.exactKeywords) { + throw new Error400(this.options.language, 'errors.eagleEye.keywordsMissing') + } + + // We need at least one platform + if (!data.platforms || data.platforms.length === 0) { + throw new Error400(this.options.language, 'errors.eagleEye.platformMissing') + } + + // Make sure platforms are in the allowed list + const platforms = Object.values(EagleEyePlatforms) as string[] + data.platforms.forEach((platform) => { + if (!platforms.includes(platform)) { + throw new Error400( + this.options.language, + 'errors.eagleEye.platformInvalid', + platform, + platforms.join(', '), + ) + } + }) + + // We need a date. Make sure it's in the allowed list. + const publishedDates = Object.values(EagleEyePublishedDates) as string[] + if (publishedDates.indexOf(data.publishedDate) === -1) { + throw new Error400( + this.options.language, + 'errors.eagleEye.publishedDateMissing', + publishedDates.join(', '), + ) + } + + // Remove any extra fields + return lodash.pick(data, [ + 'keywords', + 'exactKeywords', + 'excludedKeywords', + 'publishedDate', + 'platforms', + ]) + } + + /** + * Validate and normalize email digest settings. + * @param data Email digest settings of type EagleEyeEmailDigestSettings + * @param feed Standard feed settings of type EagleEyeFeedSettings + * @returns The normalized email digest settings if the input is valid. Otherwise a 400 Error. + */ + getEmailDigestSettings(data: EagleEyeEmailDigestSettings, feed: EagleEyeFeedSettings) { + // If the matchFeedSettings option is toggled, we set the email feed settings to the standard feed settings. + // Otherwise, we validate and normalize the email feed settings. + if (!data.matchFeedSettings) { + data.feed = this.getFeed(data.feed) + } else { + data.feed = feed + } + + // Make sure the email exists and is valid + const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + if (!emailRegex.test(data.email)) { + throw new Error400(this.options.language, 'errors.eagleEye.emailInvalid') + } + + // get next email trigger time + data.nextEmailAt = EagleEyeSettingsService.getNextEmailDigestDate(data) + + // Make sure the time exists and is valid + const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]?$/ + if (!timeRegex.test(data.time)) { + throw new Error400(this.options.language, 'errors.eagleEye.timeInvalid') + } + + // Remove any extra fields + return lodash.pick(data, [ + 'email', + 'frequency', + 'time', + 'matchFeedSettings', + 'feed', + 'nextEmailAt', + ]) + } + + /** + * Finds the next email digest send time using the frequency set by the user + * eagleEyeSettings.emailDigest.nextEmailAt will be + * set to actual send time minus 5 minutes. + * This serves as a buffer for cronjobs - Email crons will fire + * at every half hour (10:00, 10:30, 11:00,...) to ensure the + * correct send time set by the user. + * @param settings + * @returns next email date as iso string + */ + static getNextEmailDigestDate(settings: EagleEyeEmailDigestSettings): string { + const now = moment() + + let nextEmailAt: string = '' + + switch (settings.frequency) { + case EagleEyeEmailDigestFrequency.DAILY: + nextEmailAt = moment(settings.time, 'HH:mm').subtract(5, 'minutes').toISOString() + + // if send time has passed for today, set it to next day + if (now > moment(settings.time, 'HH:mm')) { + nextEmailAt = moment(settings.time, 'HH:mm').add(1, 'day').toISOString() + } + break + case EagleEyeEmailDigestFrequency.WEEKLY: + const [hour, minute] = settings.time.split(':') + const startOfWeek = moment() + .startOf('isoWeek') + .set('hour', parseInt(hour, 10)) + .set('minute', parseInt(minute, 10)) + .subtract(5, 'minutes') + + nextEmailAt = startOfWeek.toISOString() + + // if send time has passed for this week, set it to next week + if (now > startOfWeek) { + nextEmailAt = startOfWeek.add(1, 'week').toISOString() + } + break + default: + throw new Error(`Unknown email digest frequency: ${settings.frequency}`) + } + + return nextEmailAt + } + + /** + * Validate, normalize and update EagleEye settings. + * @param data Input of type EagleEyeSettings + * @returns Normalized EagleEyeSettings if the input is valid. Otherwise a 400 Error. + */ + async update(data: EagleEyeSettings): Promise { + const transaction = await SequelizeRepository.createTransaction(this.options) + try { + // At this point onboarded is true always + data.onboarded = true + + // Validate and normalize feed settings + data.feed = this.getFeed(data.feed) + + // If an email digest was sent, validate and normalize email digest settings + // Otherwise, set email digest to false + if (data.emailDigestActive && data.emailDigest) { + data.emailDigest = this.getEmailDigestSettings(data.emailDigest, data.feed) + } + + // Remove any extra fields + data = lodash.pick(data, [ + 'onboarded', + 'feed', + 'emailDigestActive', + 'emailDigest', + 'aiReplies', + ]) + + // Update the user's EagleEye settings + const userOut = await UserRepository.updateEagleEyeSettings( + this.options.currentUser.id, + data, + { ...this.options, transaction }, + ) + + await SequelizeRepository.commitTransaction(transaction) + + // Track the events in Segment + const settingsOut: EagleEyeSettings = userOut.eagleEyeSettings + + if (data.emailDigestActive) { + track( + 'Eagle Eye email settings updated', + { + email: settingsOut.emailDigest.email, + frequency: settingsOut.emailDigest.frequency, + time: settingsOut.emailDigest.time, + matchFeedSettings: settingsOut.emailDigest.matchFeedSettings, + platforms: settingsOut.emailDigest.feed.platforms, + publishedDate: settingsOut.emailDigest.feed.publishedDate, + keywords: settingsOut.emailDigest.feed.keywords, + exactKeywords: settingsOut.emailDigest.feed.exactKeywords, + excludeKeywords: settingsOut.emailDigest.feed.excludedKeywords, + }, + { ...this.options }, + ) + } else { + track( + 'Eagle Eye settings updated', + { + onboarded: settingsOut.onboarded, + emailDigestActive: settingsOut.emailDigestActive, + platforms: settingsOut.feed.platforms, + publishedDate: settingsOut.feed.publishedDate, + keywords: settingsOut.feed.keywords, + exactKeywords: settingsOut.feed.exactKeywords, + excludeKeywords: settingsOut.feed.excludedKeywords, + }, + { ...this.options }, + ) + } + + return userOut.eagleEyeSettings + } catch (error) { + await SequelizeRepository.rollbackTransaction(transaction) + + SequelizeRepository.handleUniqueFieldError(error, this.options.language, 'EagleEyeContent') + + throw error + } + } +} diff --git a/backend/src/services/emailSender.ts b/backend/src/services/emailSender.ts index 975b0ca268..c0ab976ce5 100644 --- a/backend/src/services/emailSender.ts +++ b/backend/src/services/emailSender.ts @@ -30,6 +30,7 @@ export default class EmailSender extends LoggingBase { WEEKLY_ANALYTICS: SENDGRID_CONFIG.templateWeeklyAnalytics, INTEGRATION_DONE: SENDGRID_CONFIG.templateIntegrationDone, CSV_EXPORT: SENDGRID_CONFIG.templateCsvExport, + EAGLE_EYE_DIGEST: SENDGRID_CONFIG.templateEagleEyeDigest, } } diff --git a/backend/src/services/premium/enrichment/memberEnrichmentService.ts b/backend/src/services/premium/enrichment/memberEnrichmentService.ts index 93b3075795..3c529eea67 100644 --- a/backend/src/services/premium/enrichment/memberEnrichmentService.ts +++ b/backend/src/services/premium/enrichment/memberEnrichmentService.ts @@ -29,7 +29,7 @@ import RedisPubSubEmitter from '../../../utils/redis/pubSubEmitter' import { createRedisClient } from '../../../utils/redis' import { ApiWebsocketMessage } from '../../../types/mq/apiWebsocketMessage' import MemberEnrichmentCacheRepository from '../../../database/repositories/memberEnrichmentCacheRepository' -import track from '../../../segment/telemetryTrack' +import track from '../../../segment/track' export default class MemberEnrichmentService extends LoggingBase { options: IServiceOptions diff --git a/backend/src/types/common.ts b/backend/src/types/common.ts index e4cbee23eb..a21669f9eb 100644 --- a/backend/src/types/common.ts +++ b/backend/src/types/common.ts @@ -5,6 +5,13 @@ export interface PageData { offset: number } +export interface QueryData { + filter?: any + orderBy?: string + limit?: number + offset?: number +} + export interface SearchCriteria { limit?: number offset?: number diff --git a/backend/src/types/eagleEyeTypes.ts b/backend/src/types/eagleEyeTypes.ts new file mode 100644 index 0000000000..4e4f12f7a2 --- /dev/null +++ b/backend/src/types/eagleEyeTypes.ts @@ -0,0 +1,100 @@ +export enum EagleEyeActionType { + THUMBS_UP = 'thumbs-up', + THUMBS_DOWN = 'thumbs-down', + BOOKMARK = 'bookmark', +} + +export interface EagleEyeAction { + id?: string + type: EagleEyeActionType + timestamp: Date | string + createdAt?: Date | string + updatedAt?: Date | string +} + +export interface EagleEyeContent { + id?: string + platform: string + post: any + url: string + actions?: EagleEyeAction[] + tenantId: string + postedAt: string + createdAt?: Date | string + updatedAt?: Date | string +} + +export interface EagleEyeFeedSettings { + keywords: string[] + exactKeywords: string[] + excludedKeywords: string[] + publishedDate: string + platforms: string[] +} + +export interface EagleEyeEmailDigestSettings { + email: string + frequency: EagleEyeEmailDigestFrequency + time: string + nextEmailAt: string + feed: EagleEyeFeedSettings + matchFeedSettings: boolean +} + +export enum EagleEyeEmailDigestFrequency { + DAILY = 'daily', + WEEKLY = 'weekly', +} + +export interface EagleEyeSettings { + onboarded: boolean + feed: EagleEyeFeedSettings + emailDigestActive: boolean + emailDigest?: EagleEyeEmailDigestSettings + aiReplies: boolean +} + +// Enum for EagleEyePlatforms +export enum EagleEyePlatforms { + GITHUB = 'github', + HACKERNEWS = 'hackernews', + DEVTO = 'devto', + REDDIT = 'reddit', + MEDIUM = 'medium', + STACKOVERFLOW = 'stackoverflow', + TWITTER = 'twitter', + YOUTUBE = 'youtube', + PRODUCTHUNT = 'producthunt', + KAGGLE = 'kaggle', + HASHNODE = 'hashnode', + LINKEDIN = 'linkedin', +} + +export enum EagleEyePublishedDates { + LAST_24_HOURS = 'Last 24h', + LAST_7_DAYS = 'Last 7d', + LAST_14_DAYS = 'Last 14d', + LAST_30_DAYS = 'Last 30d', + LAST_90_DAYS = 'Last 90d', +} + +export interface EagleEyeRawPost { + description: string + title: string + thumbnail?: string + url: string + platform: string + date: string +} + +export interface EagleEyePostWithActions { + post: { + description: string + title: string + thumbnail?: string + } + url: string + platform: string + postedAt: string + actions: EagleEyeAction[] +} diff --git a/backend/src/types/recurringEmailsHistoryTypes.ts b/backend/src/types/recurringEmailsHistoryTypes.ts new file mode 100644 index 0000000000..af5a1676a8 --- /dev/null +++ b/backend/src/types/recurringEmailsHistoryTypes.ts @@ -0,0 +1,13 @@ +export interface RecurringEmailsHistoryData { + id?: string + tenantId: string + type: RecurringEmailType + weekOfYear?: string + emailSentAt: string + emailSentTo: string[] +} + +export enum RecurringEmailType { + WEEKLY_ANALYTICS = 'weekly-analytics', + EAGLE_EYE_DIGEST = 'eagle-eye-digest', +} diff --git a/backend/src/types/weeklyAnalyticsEmailsHistoryTypes.ts b/backend/src/types/weeklyAnalyticsEmailsHistoryTypes.ts deleted file mode 100644 index 7d3f6923a1..0000000000 --- a/backend/src/types/weeklyAnalyticsEmailsHistoryTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface WeeklyAnalyticsEmailsHistoryData { - id?: string - tenantId: string - weekOfYear: string - emailSentAt: string - emailSentTo: string[] -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2e1af5178f..e25f7daef0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,8 @@ "@tiptap/extension-placeholder": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199", "@tiptap/vue-3": "^2.0.0-beta.199", + "@vuelidate/core": "^2.0.0", + "@vuelidate/validators": "^2.0.0", "@vueuse/core": "^9.7.0", "apollo-boost": "^0.4.9", "axios": "^0.22.0", @@ -50,12 +52,14 @@ "vue-json-pretty": "^2.2.2", "vue-router": "^4.1.5", "vue3-click-away": "^1.2.4", + "vue3-lazyload": "^0.3.6", "vuedraggable": "^4.1.0", "vuex": "^4.0.2", "xlsx": "^0.17.2", "yup": "^0.32.11" }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.2", "@tiptap/extension-link": "^2.0.0-beta.202", "@vue/cli-plugin-babel": "^5.0.8", "@vue/cli-plugin-eslint": "^5.0.1", @@ -2608,6 +2612,15 @@ "integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==", "dev": true }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz", + "integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==", + "dev": true, + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, "node_modules/@tiptap/core": { "version": "2.0.0-beta.199", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.0-beta.199.tgz", @@ -4050,6 +4063,90 @@ "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "dev": true }, + "node_modules/@vuelidate/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.0.tgz", + "integrity": "sha512-xIFgdQlScO0aaSZ0wTGPJh8YcTMNAj5veI8yPgiAyxOT+GV7vNQFiU1vpYWCL4cklkkhYvRRSC2OEX7YOZNmPQ==", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/core/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.0.tgz", + "integrity": "sha512-fQQcmDWfz7pyH5/JPi0Ng2GEgNK1pUHn/Z/j5rG/Q+HwhgIXvJblTPcZwKOj1ABL7V4UVuGKECvZCDHNGOwdrg==", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@vueuse/core": { "version": "9.7.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.7.0.tgz", @@ -14793,6 +14890,48 @@ "resolved": "https://registry.npmjs.org/vue3-click-away/-/vue3-click-away-1.2.4.tgz", "integrity": "sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==" }, + "node_modules/vue3-lazyload": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/vue3-lazyload/-/vue3-lazyload-0.3.6.tgz", + "integrity": "sha512-UcVnEN9JzxeFBa7nNAPWKXHTtvVAzWYhBSvRU+Gmx9MdTGLWKwjZiNSyB1Os25jr9HaFHWY0DaU8uugXkGu9Gw==", + "dependencies": { + "vue-demi": "^0.12.5" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue3-lazyload/node_modules/vue-demi": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz", + "integrity": "sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", @@ -17638,6 +17777,13 @@ "integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==", "dev": true }, + "@tailwindcss/line-clamp": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz", + "integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==", + "dev": true, + "requires": {} + }, "@tiptap/core": { "version": "2.0.0-beta.199", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.0-beta.199.tgz", @@ -18741,6 +18887,38 @@ "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "dev": true }, + "@vuelidate/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.0.tgz", + "integrity": "sha512-xIFgdQlScO0aaSZ0wTGPJh8YcTMNAj5veI8yPgiAyxOT+GV7vNQFiU1vpYWCL4cklkkhYvRRSC2OEX7YOZNmPQ==", + "requires": { + "vue-demi": "^0.13.11" + }, + "dependencies": { + "vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "requires": {} + } + } + }, + "@vuelidate/validators": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.0.tgz", + "integrity": "sha512-fQQcmDWfz7pyH5/JPi0Ng2GEgNK1pUHn/Z/j5rG/Q+HwhgIXvJblTPcZwKOj1ABL7V4UVuGKECvZCDHNGOwdrg==", + "requires": { + "vue-demi": "^0.13.11" + }, + "dependencies": { + "vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "requires": {} + } + } + }, "@vueuse/core": { "version": "9.7.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.7.0.tgz", @@ -26776,6 +26954,22 @@ "resolved": "https://registry.npmjs.org/vue3-click-away/-/vue3-click-away-1.2.4.tgz", "integrity": "sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==" }, + "vue3-lazyload": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/vue3-lazyload/-/vue3-lazyload-0.3.6.tgz", + "integrity": "sha512-UcVnEN9JzxeFBa7nNAPWKXHTtvVAzWYhBSvRU+Gmx9MdTGLWKwjZiNSyB1Os25jr9HaFHWY0DaU8uugXkGu9Gw==", + "requires": { + "vue-demi": "^0.12.5" + }, + "dependencies": { + "vue-demi": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz", + "integrity": "sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==", + "requires": {} + } + } + }, "vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0215c0db1b..3aa8021855 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "@tiptap/extension-placeholder": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199", "@tiptap/vue-3": "^2.0.0-beta.199", + "@vuelidate/core": "^2.0.0", + "@vuelidate/validators": "^2.0.0", "@vueuse/core": "^9.7.0", "apollo-boost": "^0.4.9", "axios": "^0.22.0", @@ -60,12 +62,14 @@ "vue-json-pretty": "^2.2.2", "vue-router": "^4.1.5", "vue3-click-away": "^1.2.4", + "vue3-lazyload": "^0.3.6", "vuedraggable": "^4.1.0", "vuex": "^4.0.2", "xlsx": "^0.17.2", "yup": "^0.32.11" }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.2", "@tiptap/extension-link": "^2.0.0-beta.202", "@vue/cli-plugin-babel": "^5.0.8", "@vue/cli-plugin-eslint": "^5.0.1", diff --git a/frontend/public/images/eagle-eye/banner.png b/frontend/public/images/eagle-eye/banner.png new file mode 100644 index 0000000000..63eee00d99 Binary files /dev/null and b/frontend/public/images/eagle-eye/banner.png differ diff --git a/frontend/public/images/eagle-eye/onboard-discover.png b/frontend/public/images/eagle-eye/onboard-discover.png new file mode 100644 index 0000000000..6bc7da0f66 Binary files /dev/null and b/frontend/public/images/eagle-eye/onboard-discover.png differ diff --git a/frontend/public/images/eagle-eye/onboard-engage.png b/frontend/public/images/eagle-eye/onboard-engage.png new file mode 100644 index 0000000000..18395aa0f9 Binary files /dev/null and b/frontend/public/images/eagle-eye/onboard-engage.png differ diff --git a/frontend/public/images/eagle-eye/onboard-grow.png b/frontend/public/images/eagle-eye/onboard-grow.png new file mode 100644 index 0000000000..6f0ad01122 Binary files /dev/null and b/frontend/public/images/eagle-eye/onboard-grow.png differ diff --git a/frontend/public/images/integrations/kaggle.png b/frontend/public/images/integrations/kaggle.png new file mode 100644 index 0000000000..10a009ff1c Binary files /dev/null and b/frontend/public/images/integrations/kaggle.png differ diff --git a/frontend/public/images/integrations/producthunt.png b/frontend/public/images/integrations/producthunt.png new file mode 100644 index 0000000000..c1fa2bfb2b Binary files /dev/null and b/frontend/public/images/integrations/producthunt.png differ diff --git a/frontend/src/assets/scss/buttons.scss b/frontend/src/assets/scss/buttons.scss index ae8706edd8..8c62c4990e 100644 --- a/frontend/src/assets/scss/buttons.scss +++ b/frontend/src/assets/scss/buttons.scss @@ -93,8 +93,9 @@ &:active { @apply bg-gray-200 text-gray-600 border-2 border-gray-200 border-solid; } - &[disabled] { - @apply cursor-not-allowed bg-white text-gray-400 border-2 border-gray-100 border-solid; + &[disabled], + &.disabled { + @apply cursor-not-allowed bg-white text-gray-400 border-none; } } diff --git a/frontend/src/assets/scss/form/form.scss b/frontend/src/assets/scss/form/form.scss index b84b805a5e..29709f5bb2 100644 --- a/frontend/src/assets/scss/form/form.scss +++ b/frontend/src/assets/scss/form/form.scss @@ -30,3 +30,15 @@ @apply flex flex-row-reverse gap-1 mb-1 text-xs text-gray-900 font-semibold; } } + +.el-form-item.no-margin .el-form-item__content{ + @apply mb-0; +} + +.el-form-item.is-error-relative .el-form-item__error{ + position: relative; + top: 0%; +} +.el-form-item.is-error-above .el-form-item__error{ + top: -1rem; +} \ No newline at end of file diff --git a/frontend/src/assets/scss/form/radio.scss b/frontend/src/assets/scss/form/radio.scss index 5ef6d6b367..0ec8d38638 100644 --- a/frontend/src/assets/scss/form/radio.scss +++ b/frontend/src/assets/scss/form/radio.scss @@ -18,6 +18,12 @@ @apply text-brand-500; } } + + &.is-small { + .el-radio-button__inner { + @apply p-2; + } + } } &.radio-chips { diff --git a/frontend/src/assets/scss/form/select.scss b/frontend/src/assets/scss/form/select.scss index ae28831194..92f6a185dd 100644 --- a/frontend/src/assets/scss/form/select.scss +++ b/frontend/src/assets/scss/form/select.scss @@ -69,8 +69,7 @@ } // Selected icon for single selection -.el-select-dropdown - .el-select-dropdown__item.selected::after { +.el-select-dropdown .el-select-dropdown__item:not(.no-checkmark).selected::after { content: ''; position: absolute; top: 50%; @@ -117,7 +116,7 @@ } // Select dropdown item selected - &.selected { + &.selected, &.selected.hover { @apply font-medium bg-brand-50 text-gray-900; } diff --git a/frontend/src/assets/scss/layout.scss b/frontend/src/assets/scss/layout.scss index dc15dd8a00..7c06b09504 100644 --- a/frontend/src/assets/scss/layout.scss +++ b/frontend/src/assets/scss/layout.scss @@ -79,37 +79,6 @@ hr { flex-wrap: wrap; } -[class^='text-limit'], -[class*=' text-limit'] { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-box-orient: vertical; -} - -// Limits text to 1 line -.text-limit-1 { - -webkit-line-clamp: 1; - line-clamp: 1; -} - -// Limits text to 1 line -.text-limit-2 { - -webkit-line-clamp: 2; - line-clamp: 2; -} -// Limits text to 1 line -.text-limit-3 { - -webkit-line-clamp: 3; - line-clamp: 3; -} - -// Limits text to 4 lines -.text-limit-4 { - -webkit-line-clamp: 4; - line-clamp: 4; -} - // Disable autocomplete background input:-webkit-autofill, input:-webkit-autofill:hover, diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index c3035897cd..4b4a22790a 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -133,6 +133,13 @@ const en = { } }, + emailDigest: { + fields: { + email: 'Email', + frequency: 'Frequency', + time: 'Time' + } + }, member: { name: 'member', label: 'Members', diff --git a/frontend/src/jsons/eagle-eye-sources.json b/frontend/src/jsons/eagle-eye-sources.json deleted file mode 100644 index db8fcc6970..0000000000 --- a/frontend/src/jsons/eagle-eye-sources.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "platform": "hacker_news", - "name": "Hacker News", - "image": - "https://directory-cdn.anymailfinder.com/a644fc61-3287-4d9e-9b7f-c9859fa50a8f" - }, - { - "platform": "devto", - "name": "DEV", - "image": - "https://i.pinimg.com/originals/23/49/2d/23492d49eefc1794c50377c2613baa00.jpg" - }, - { - "platform": "github", - "name": "GitHub", - "image": - "https://cdn-icons-png.flaticon.com/512/25/25231.png" - }, - { - "platform": "twitter", - "name": "Twitter", - "image": - "https://cdn-icons-png.flaticon.com/512/733/733579.png" - }, - { - "platform": "stackoverflow", - "name": "Stack Overflow", - "image": - "https://cdn-icons-png.flaticon.com/512/2111/2111628.png" - }, - { - "platform": "reddit", - "name": "Reddit", - "image": - "https://cdn-icons-png.flaticon.com/512/2111/2111589.png" - } -] \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index a09bf0ce01..6f8baa2962 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -24,6 +24,7 @@ import App from '@/app.vue' import { vueSanitizeOptions } from '@/plugins/sanitize' import marked from '@/plugins/marked' import posthog from 'posthog-js' +import VueLazyLoad from 'vue3-lazyload' i18nInit() /** @@ -57,6 +58,8 @@ i18nInit() app.use(Vue3Sanitize, vueSanitizeOptions) app.use(VueClickAway) app.use(marked) + app.use(VueLazyLoad) + app.config.productionTip = process.env.NODE_ENV === 'production' diff --git a/frontend/src/modules/conversation/components/conversation-reply.vue b/frontend/src/modules/conversation/components/conversation-reply.vue index 00a8394697..6369bcb90b 100644 --- a/frontend/src/modules/conversation/components/conversation-reply.vue +++ b/frontend/src/modules/conversation/components/conversation-reply.vue @@ -44,7 +44,7 @@ :display-title="false" class="text-sm" :class="{ - 'text-limit-1': !displayContent && !showMore + 'line-clamp-1': !displayContent && !showMore }" :show-more="showMore" :limit="limit" diff --git a/frontend/src/modules/layout/components/layout.vue b/frontend/src/modules/layout/components/layout.vue index 5ae316fd73..1926375d47 100644 --- a/frontend/src/modules/layout/components/layout.vue +++ b/frontend/src/modules/layout/components/layout.vue @@ -3,9 +3,13 @@ -
+
- +
@@ -159,7 +160,6 @@ import { mapActions, mapGetters } from 'vuex' import Banner from '@/shared/banner/banner.vue' import identify from '@/shared/monitoring/identify' import ConfirmDialog from '@/shared/dialog/confirm-dialog.js' -import moment from 'moment' import config from '@/config' import Message from '@/shared/message/message' @@ -188,7 +188,16 @@ export default { currentUser: 'auth/currentUser', currentTenant: 'auth/currentTenant', integrationsInProgress: 'integration/inProgress', - integrationsWithErrors: 'integration/withErrors' + integrationsWithErrors: 'integration/withErrors', + showSampleDataAlert: 'tenant/showSampleDataAlert', + showIntegrationsErrorAlert: + 'tenant/showIntegrationsErrorAlert', + showIntegrationsInProgressAlert: + 'tenant/showIntegrationsInProgressAlert', + showTenantCreatingAlert: + 'tenant/showTenantCreatingAlert', + showPMFSurveyAlert: 'tenant/showPMFSurveyAlert', + showBanner: 'tenant/showBanner' }), integrationsInProgressToString() { const arr = this.integrationsInProgress.map( @@ -206,52 +215,6 @@ export default { ) } }, - shouldShowIntegrationsInProgressAlert() { - return this.integrationsInProgress.length > 0 - }, - shouldShowIntegrationsErrorAlert() { - return ( - this.integrationsWithErrors.length > 0 && - this.$route.name !== 'integration' - ) - }, - shouldShowSampleDataAlert() { - return this.currentTenant.hasSampleData - }, - shouldShowPMFSurveyAlert() { - const timestampSignup = new Date( - this.currentUser.createdAt - ).getTime() - const timeStamp4WeeksAgo = - new Date().getTime() - 4 * 7 * 24 * 60 * 60 * 1000 - const timeStamp2023 = new Date('2023-01-01').getTime() - - return ( - timestampSignup >= timeStamp2023 && - timestampSignup <= timeStamp4WeeksAgo && - config.formbricks.url && - config.formbricks.pmfFormId && - !this.hidePmfBanner - ) - }, - shouldShowTenantCreatingAlert() { - return ( - moment().diff( - moment(this.currentTenant.createdAt), - 'minutes' - ) <= 2 - ) - }, - computedBannerWrapperClass() { - return { - 'pt-16': - this.shouldShowSampleDataAlert || - this.shouldShowIntegrationsErrorAlert || - this.shouldShowIntegrationsInProgressAlert || - this.shouldShowTenantCreatingAlert || - this.shouldShowPMFSurveyAlert - } - }, elMainStyle() { if (this.isMobile && !this.collapsed) { return { @@ -367,30 +330,6 @@ export default { // as long as it's not one of the above reserved names. }, - account: { - id: this.currentTenant.id, // Required if using Pendo Feedback, default uses the value 'ACCOUNT-UNIQUE-ID' - name: this.currentTenant.name, // Optional - is_paying: this.currentTenant.plan !== 'Essential' // Recommended if using Pendo Feedback - // monthly_value:// Recommended if using Pendo Feedback - // planLevel: // Optional - // planPrice: // Optional - // creationDate: // Optional - - // You can add any additional account level key-values here, - // as long as it's not one of the above reserved names. - } - }) - console.log({ - visitor: { - id: this.currentUser.id, // Required if user is logged in, default creates anonymous ID - email: this.currentUser.email, // Recommended if using Pendo Feedback, or NPS Email - full_name: this.currentUser.fullName // Recommended if using Pendo Feedback - // role: // Optional - - // You can add any additional visitor level key-values here, - // as long as it's not one of the above reserved names. - }, - account: { id: this.currentTenant.id, // Required if using Pendo Feedback, default uses the value 'ACCOUNT-UNIQUE-ID' name: this.currentTenant.name, // Optional diff --git a/frontend/src/modules/layout/layout-page-content.js b/frontend/src/modules/layout/layout-page-content.js new file mode 100644 index 0000000000..286aff748c --- /dev/null +++ b/frontend/src/modules/layout/layout-page-content.js @@ -0,0 +1,47 @@ +export const pageContent = { + organizations: { + icon: 'ri-community-line', + headerTitle: 'Organizations', + title: + 'Get a pulse of the organizations represented in your community', + mainContent: + 'Get a complete organization directory that you can search, filter, and sort instantly. Each organization also has its own profile page, which highlights key information about that organization and all the community members that belong to it. Keeping a pulse of which organizations your members are representing is extremely important for a successful bottom-up growth strategy.', + imageSrc: '/images/paywall/organizations.png', + imageClass: 'mt-8', + secondaryContent: + 'Organizations are companies or entities within your community. If a member that works at a certain company joins your community, that company will be added as an organization.', + featuresList: [] + }, + eagleEye: { + icon: 'ri-search-eye-line', + headerTitle: 'Eagle Eye', + title: 'Locate & engage with the right content', + mainContent: + "Our Eagle Eye app allows you to monitor different community platforms to find relevant content to engage with, helping you to gain developers' mindshare and grow your community organically.", + imageSrc: '/images/paywall/eagle-eye.png', + imageClass: '', + featuresList: [ + { + icon: 'ri-eye-2-line', + title: + 'Keep an Eagle Eye view on relevant content & posts to grow', + content: + "On top of monitoring everything going on within your community, crowd.dev's Eagle Eye application is focused on helping you engage with relevant content outside of your community to help grow it further." + }, + { + icon: 'ri-apps-2-line', + title: + 'Identify and engage with content across platforms', + content: + 'All you need to do is type in a few keywords and Eagle Eye will give you the most recent and relevant content to enage with accross platforms like HackerNews and Dev to connect you with like-minded people.' + }, + { + icon: 'ri-character-recognition-line', + title: + 'Search powered by Natural Language Processing', + content: + "The search engine behind Eagle Eye is based on a semantic model that delivers the most relevant content even when it doesn't match your keywords." + } + ] + } +} diff --git a/frontend/src/modules/layout/pages/paywall-page.vue b/frontend/src/modules/layout/pages/paywall-page.vue index d52939b219..f0a2358a07 100644 --- a/frontend/src/modules/layout/pages/paywall-page.vue +++ b/frontend/src/modules/layout/pages/paywall-page.vue @@ -97,6 +97,7 @@ import config from '@/config' import AppPageWrapper from '@/shared/layout/page-wrapper.vue' import { defineProps, computed } from 'vue' import { premiumFeatureCopy } from '@/utils/posthog' +import { pageContent } from '@/modules/layout/layout-page-content' const props = defineProps({ module: { @@ -109,54 +110,6 @@ const page = computed(() => pageContent[props.module]) const computedFeaturePlan = computed(() => { return config.isCommunityVersion ? 'Custom' : 'Growth' }) - -const pageContent = { - organizations: { - icon: 'ri-community-line', - headerTitle: 'Organizations', - title: - 'Get a pulse of the organizations represented in your community', - mainContent: - 'Get a complete organization directory that you can search, filter, and sort instantly. Each organization also has its own profile page, which highlights key information about that organization and all the community members that belong to it. Keeping a pulse of which organizations your members are representing is extremely important for a successful bottom-up growth strategy.', - imageSrc: '/images/paywall/organizations.png', - imageClass: 'mt-8', - secondaryContent: - 'Organizations are companies or entities within your community. If a member that works at a certain company joins your community, that company will be added as an organization.', - featuresList: [] - }, - eagleEye: { - icon: 'ri-search-eye-line', - headerTitle: 'Eagle Eye', - title: 'Locate & engage with the right content', - mainContent: - 'Our Eagle Eye app allows you to monitor different community platforms to find relevant content to engage with, helping you to gain developers’ mindshare and grow your community organically', - imageSrc: '/images/paywall/eagle-eye.png', - imageClass: '', - featuresList: [ - { - icon: 'ri-eye-2-line', - title: - 'Keep an Eagle Eye view on relevant content & posts to grow', - content: - 'On top of monitoring everything going on within your community, crowd.dev’s Eagle Eye application is focused on helping you engage with relevant content outside of your community to help grow it further.' - }, - { - icon: 'ri-apps-2-line', - title: - 'Identify and engage with content across platforms', - content: - 'All you need to do is type in a few keywords and Eagle Eye will give you the most recent and relevant content to enage with accross platforms like HackerNews and Dev to connect you with like-minded people.' - }, - { - icon: 'ri-character-recognition-line', - title: - 'Search powered by Natural Language Processing', - content: - 'The search engine behind Eagle Eye is based on a semantic model that delivers the most relevant content even when it doesn’t match your keywords.' - } - ] - } -} diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-list-item.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-list-item.vue deleted file mode 100644 index 251cbcbe1d..0000000000 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-list-item.vue +++ /dev/null @@ -1,184 +0,0 @@ - - - - - diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-list.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-list.vue deleted file mode 100644 index 72d1fbf0e0..0000000000 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-list.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-platforms-drawers.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-platforms-drawers.vue new file mode 100644 index 0000000000..379a25d679 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/eagle-eye-platforms-drawers.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-platforms.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-platforms.vue new file mode 100644 index 0000000000..8b722bb76a --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/eagle-eye-platforms.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-published-date.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-published-date.vue new file mode 100644 index 0000000000..cda221bbdb --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/eagle-eye-published-date.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-search.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-search.vue deleted file mode 100644 index ead673db3e..0000000000 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-search.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-sorter.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-sorter.vue deleted file mode 100644 index f551718a79..0000000000 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-sorter.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/frontend/src/premium/eagle-eye/components/eagle-eye-tabs.vue b/frontend/src/premium/eagle-eye/components/eagle-eye-tabs.vue deleted file mode 100644 index 92d7be79d7..0000000000 --- a/frontend/src/premium/eagle-eye/components/eagle-eye-tabs.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - - diff --git a/frontend/src/premium/eagle-eye/components/form/eagle-eye-settings-include.vue b/frontend/src/premium/eagle-eye/components/form/eagle-eye-settings-include.vue new file mode 100644 index 0000000000..b89ac5b8a4 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/form/eagle-eye-settings-include.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-card.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-card.vue new file mode 100644 index 0000000000..9433e830c7 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-card.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-drawer.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-drawer.vue new file mode 100644 index 0000000000..0ffefee723 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-email-digest-drawer.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-list.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-list.vue new file mode 100644 index 0000000000..05679794b6 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-list.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-card.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-card.vue new file mode 100644 index 0000000000..cb7c5da685 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-card.vue @@ -0,0 +1,37 @@ + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-state.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-state.vue new file mode 100644 index 0000000000..a13116dc17 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-loading-state.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-result-card.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-result-card.vue new file mode 100644 index 0000000000..46852a9135 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-result-card.vue @@ -0,0 +1,676 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings-drawer.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings-drawer.vue new file mode 100644 index 0000000000..cdb6afc57d --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings-drawer.vue @@ -0,0 +1,331 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings.vue new file mode 100644 index 0000000000..93eff83ccb --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-settings.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/list/eagle-eye-tabs.vue b/frontend/src/premium/eagle-eye/components/list/eagle-eye-tabs.vue new file mode 100644 index 0000000000..9095dcfff8 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/list/eagle-eye-tabs.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-banner.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-banner.vue new file mode 100644 index 0000000000..f544099763 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-banner.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-footer.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-footer.vue new file mode 100644 index 0000000000..3669797564 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-footer.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-intro-step.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-intro-step.vue new file mode 100644 index 0000000000..67d1653c80 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-intro-step.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-keywords-step.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-keywords-step.vue new file mode 100644 index 0000000000..44056085ee --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-keywords-step.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-platforms-step.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-platforms-step.vue new file mode 100644 index 0000000000..e7e08b4fbb --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-platforms-step.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-summary-step.vue b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-summary-step.vue new file mode 100644 index 0000000000..28963beeb9 --- /dev/null +++ b/frontend/src/premium/eagle-eye/components/onboard/eagle-eye-summary-step.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/premium/eagle-eye/constants/eagle-eye-date-published.json b/frontend/src/premium/eagle-eye/constants/eagle-eye-date-published.json new file mode 100644 index 0000000000..5c7b5a0164 --- /dev/null +++ b/frontend/src/premium/eagle-eye/constants/eagle-eye-date-published.json @@ -0,0 +1,17 @@ +[ + { + "label": "Last 24h" + }, + { + "label": "Last 7d" + }, + { + "label": "Last 14d" + }, + { + "label": "Last 30d" + }, + { + "label": "Last 90d" + } +] diff --git a/frontend/src/premium/eagle-eye/constants/eagle-eye-platforms.json b/frontend/src/premium/eagle-eye/constants/eagle-eye-platforms.json new file mode 100644 index 0000000000..bf6001e1c2 --- /dev/null +++ b/frontend/src/premium/eagle-eye/constants/eagle-eye-platforms.json @@ -0,0 +1,46 @@ +{ + "devto": { + "label": "DEV", + "img": "https://cdn-icons-png.flaticon.com/512/5969/5969051.png" + }, + "github": { + "label": "GitHub", + "img": "https://cdn-icons-png.flaticon.com/512/25/25231.png" + }, + "hackernews": { + "label": "Hacker News", + "img": "/images/integrations/hackernews.svg" + }, + "hashnode": { + "label": "Hashnode", + "img": "https://cdn.hashnode.com/res/hashnode/image/upload/v1611902473383/CDyAuTy75.png?auto=compress" + }, + "kaggle": { + "label": "Kaggle", + "img": "/images/integrations/kaggle.png" + }, + "medium": { + "label": "Medium", + "img": "https://cdn-icons-png.flaticon.com/512/5968/5968885.png" + }, + "producthunt": { + "label": "Product Hunt", + "img": "/images/integrations/producthunt.png" + }, + "reddit": { + "label": "Reddit", + "img": "/images/integrations/reddit.svg" + }, + "stackoverflow": { + "label": "Stack Overflow", + "img": "https://cdn-icons-png.flaticon.com/512/2111/2111628.png" + }, + "twitter": { + "label": "Twitter", + "img": "https://cdn-icons-png.flaticon.com/512/733/733579.png" + }, + "youtube": { + "label": "YouTube", + "img": "https://cdn-icons-png.flaticon.com/512/1384/1384060.png" + } +} diff --git a/frontend/src/premium/eagle-eye/eagle-eye-routes.js b/frontend/src/premium/eagle-eye/eagle-eye-routes.js index 3434056dae..b3dfe3c853 100644 --- a/frontend/src/premium/eagle-eye/eagle-eye-routes.js +++ b/frontend/src/premium/eagle-eye/eagle-eye-routes.js @@ -22,6 +22,11 @@ const EagleEyeMainPage = async () => { return EagleEyePage() } +const EagleEyeOnboardPage = () => + import( + '@/premium/eagle-eye/pages/eagle-eye-onboard-page.vue' + ) + const EagleEyePage = () => import('@/premium/eagle-eye/pages/eagle-eye-page.vue') @@ -49,6 +54,16 @@ export default [ module: 'eagleEye' }, beforeEnter: async (to, _from, next) => { + // Redirect to onboard page if user is not onboarded + if (await isEagleEyeFeatureEnabled()) { + const currentUser = + store.getters['auth/currentUser'] + if (!currentUser.eagleEyeSettings?.onboarded) { + next('/eagle-eye/onboard') + return + } + } + if ( to.query.activeTab !== undefined && store.getters['eagleEye/activeView'].id !== @@ -60,6 +75,27 @@ export default [ ) } + next() + } + }, + { + name: 'eagleEyeOnboard', + path: '/eagle-eye/onboard', + component: EagleEyeOnboardPage, + exact: true, + meta: { + auth: true, + permission: Permissions.values.eagleEyeRead + }, + beforeEnter: async (to, _from, next) => { + const currentUser = + store.getters['auth/currentUser'] + // Redirect to onboard page if user is not onboarded + if (currentUser.eagleEyeSettings?.onboarded) { + next('/eagle-eye') + return + } + next() } } diff --git a/frontend/src/premium/eagle-eye/eagle-eye-service.js b/frontend/src/premium/eagle-eye/eagle-eye-service.js index 96f34aaa28..d28db197fe 100644 --- a/frontend/src/premium/eagle-eye/eagle-eye-service.js +++ b/frontend/src/premium/eagle-eye/eagle-eye-service.js @@ -1,21 +1,10 @@ import authAxios from '@/shared/axios/auth-axios' import AuthCurrentTenant from '@/modules/auth/auth-current-tenant' -import buildApiPayload from '@/shared/filter/helpers/build-api-payload' export class EagleEyeService { - static async find(id) { - const tenantId = AuthCurrentTenant.get() - - const response = await authAxios.get( - `/tenant/${tenantId}/eagleEyeContent/${id}` - ) - - return response.data - } - - static async list(filter, orderBy, limit, offset) { + static async query(filter, orderBy, limit, offset) { const body = { - filter: buildApiPayload(filter), + filter, orderBy, limit, offset @@ -31,60 +20,80 @@ export class EagleEyeService { return response.data } - static async populate(keywords) { - const data = { - exactKeywords: keywords - .filter((k) => { - return k[0] === '"' && k[k.length - 1] === '"' - }) - .map((s) => s.replaceAll('"', '')), - keywords: keywords.filter((k) => { - return k[0] !== '"' && k[k.length - 1] !== '"' - }) - } + static async search() { + const tenantId = AuthCurrentTenant.get() + const response = await authAxios.get( + `/tenant/${tenantId}/eagleEyeContent/search` + ) + + return response.data + } + + static async createContent({ post }) { const tenantId = AuthCurrentTenant.get() - await authAxios.post( + const response = await authAxios.post( `/tenant/${tenantId}/eagleEyeContent`, - data + post ) + + return response.data } - static async exclude(id) { + static async track({ event, params }) { const tenantId = AuthCurrentTenant.get() + const response = await authAxios.post( + `/tenant/${tenantId}/eagleEyeContent/track`, + { + event, + params + } + ) + return response.data + } - const response = await authAxios.put( - `/tenant/${tenantId}/eagleEyeContent/${id}`, + static async generateReply({ title, description }) { + const tenantId = AuthCurrentTenant.get() + const response = await authAxios.get( + `/tenant/${tenantId}/eagleEyeContent/reply`, { - status: 'rejected' + params: { + title, + description + } } ) + return response.data + } + + static async addAction({ postId, action }) { + const tenantId = AuthCurrentTenant.get() + + const response = await authAxios.post( + `/tenant/${tenantId}/eagleEyeContent/${postId}/action`, + action + ) return response.data } - static async engage(id) { + static async deleteAction({ postId, actionId }) { const tenantId = AuthCurrentTenant.get() - const response = await authAxios.put( - `/tenant/${tenantId}/eagleEyeContent/${id}`, - { - status: 'engaged' - } + const response = await authAxios.delete( + `/tenant/${tenantId}/eagleEyeContent/${postId}/action/${actionId}` ) return response.data } - static async revertExclude(id) { + static async updateSettings(data) { const tenantId = AuthCurrentTenant.get() const response = await authAxios.put( - `/tenant/${tenantId}/eagleEyeContent/${id}`, - { - status: null - } + `/tenant/${tenantId}/eagleEyeContent/settings`, + data ) return response.data diff --git a/frontend/src/premium/eagle-eye/eagle-eye-storage.js b/frontend/src/premium/eagle-eye/eagle-eye-storage.js new file mode 100644 index 0000000000..ea972ddc09 --- /dev/null +++ b/frontend/src/premium/eagle-eye/eagle-eye-storage.js @@ -0,0 +1,91 @@ +/** + * Storage Object + * [tenantId]: { + * [userId]: { + * posts: [] + * storageDate: '', + * } + * } + */ + +import moment from 'moment' + +export const isStorageUpdating = ({ tenantId, userId }) => { + const storage = localStorage.getItem('eagleEyeResults') + + if ( + !storage || + !JSON.parse(storage)?.[tenantId]?.[userId] + ) { + return null + } + + return !JSON.parse(storage)[tenantId][userId].storageDate +} + +export const shouldFetchNewResults = ({ + tenantId, + userId +}) => { + const storage = localStorage.getItem('eagleEyeResults') + const currentDay = moment() + + // Fetch new results if it is a new day from the previous stored one, + // or if storage is not set or if user is not set in storage + if ( + !storage || + !JSON.parse(storage)[tenantId]?.[userId] || + !currentDay.isSame( + JSON.parse(storage)[tenantId][userId].storageDate, + 'd' + ) + ) { + return true + } + + return false +} + +// Get posts from local storage +export const getResultsFromStorage = ({ + tenantId, + userId +}) => { + const storage = localStorage.getItem('eagleEyeResults') + + if (!storage) { + return null + } + + return JSON.parse(storage)[tenantId][userId].posts +} + +// Set results in storage for the given tenant and user id +export const setResultsInStorage = ({ + storageDate, + posts, + tenantId, + userId +}) => { + const storage = JSON.parse( + localStorage.getItem('eagleEyeResults') || '{}' + ) + const payload = { + posts, + storageDate + } + + // Add/update user posts in tenantId + if (storage[tenantId]) { + storage[tenantId][userId] = payload + } else { + storage[tenantId] = { + [userId]: payload + } + } + + localStorage.setItem( + 'eagleEyeResults', + JSON.stringify(storage) + ) +} diff --git a/frontend/src/premium/eagle-eye/pages/eagle-eye-onboard-page.vue b/frontend/src/premium/eagle-eye/pages/eagle-eye-onboard-page.vue new file mode 100644 index 0000000000..b8cc2b4990 --- /dev/null +++ b/frontend/src/premium/eagle-eye/pages/eagle-eye-onboard-page.vue @@ -0,0 +1,158 @@ + + + diff --git a/frontend/src/premium/eagle-eye/pages/eagle-eye-page.vue b/frontend/src/premium/eagle-eye/pages/eagle-eye-page.vue index 359cc59a13..7b68dbb5e7 100644 --- a/frontend/src/premium/eagle-eye/pages/eagle-eye-page.vue +++ b/frontend/src/premium/eagle-eye/pages/eagle-eye-page.vue @@ -1,43 +1,36 @@ + + diff --git a/frontend/src/premium/eagle-eye/store/actions.js b/frontend/src/premium/eagle-eye/store/actions.js index f553065983..4e9efbdd20 100644 --- a/frontend/src/premium/eagle-eye/store/actions.js +++ b/frontend/src/premium/eagle-eye/store/actions.js @@ -1,105 +1,385 @@ import sharedActions from '@/shared/store/actions' import { EagleEyeService } from '@/premium/eagle-eye/eagle-eye-service' import Errors from '@/shared/error/errors' +import moment from 'moment' +import { + getResultsFromStorage, + setResultsInStorage, + shouldFetchNewResults, + isStorageUpdating +} from '@/premium/eagle-eye/eagle-eye-storage' +import Message from '@/shared/message/message' export default { ...sharedActions('eagleEye'), async doFetch( - { commit, getters }, - { keepPagination = false } + { state, commit, getters, rootGetters }, + { keepPagination = false, resetStorage = false } ) { + const currentUser = rootGetters['auth/currentUser'] + const currentTenant = rootGetters['auth/currentTenant'] + const activeView = getters.activeView.id + let list = [], + count = 0, + appendToList = false + + // Edge case where new results were fetched but user changed tabs + // This is to prevent a new fetch until the previous results were loaded + if ( + activeView === 'feed' && + state.views[activeView].list.loading && + isStorageUpdating({ + tenantId: currentTenant.id, + userId: currentUser.id + }) + ) { + return + } + try { commit('FETCH_STARTED', { - keepPagination + keepPagination: resetStorage + ? false + : keepPagination, + activeView }) - const response = await EagleEyeService.list( - getters.activeView.filter, - getters.orderBy, - getters.limit, - getters.offset - ) - - if ( - getters.activeView.filter.attributes.keywords && - getters.activeView.filter.attributes.keywords.value - ?.length > 0 - ) { - localStorage.setItem( - 'eagleEye_keywords', - getters.activeView.filter.attributes.keywords - .value + // Bookmarks View + if (activeView === 'bookmarked') { + const { sorter } = getters.activeView + const response = await EagleEyeService.query( + { + action: { + type: 'bookmark', + ...(sorter === 'individualBookmarks' && { + actionById: currentUser.id + }) + } + }, + getters.orderBy, + getters.limit, + getters.offset ) + + list = response.rows + count = response.count + + // Append to existing list if offset is not 0 + // User clicked on load more button + if (getters.offset !== 0) { + appendToList = true + } } + // Feed view + else { + // Fetch for new results when + // resetStorage = true (settings were updated) + // or criteria to fetch new results = true (new day) + // or storage is waiting for results + const fetchNewResults = + resetStorage || + shouldFetchNewResults({ + tenantId: currentTenant.id, + userId: currentUser.id + }) || + isStorageUpdating({ + tenantId: currentTenant.id, + userId: currentUser.id + }) + + if (fetchNewResults) { + // Set storage to be in updating state + setResultsInStorage({ + posts: [], + storageDate: null, + tenantId: currentTenant.id, + userId: currentUser.id + }) + + list = await EagleEyeService.search() + // Set new results in local storage + setResultsInStorage({ + posts: list, + storageDate: moment(), + tenantId: currentTenant.id, + userId: currentUser.id + }) + } else { + // Get results from local storage + list = getResultsFromStorage({ + tenantId: currentTenant.id, + userId: currentUser.id + }) + } + } + + // Only update view list results if active view is the same from the initial request + // This is to prevent the user changing between tabs and the request was still loading commit('FETCH_SUCCESS', { - rows: response.rows, - count: response.count + list, + ...(count && { count }), + ...(appendToList && { appendToList }), + activeView }) } catch (error) { Errors.handle(error) - commit('FETCH_ERROR') + commit('FETCH_ERROR', { + activeView + }) } }, - async doPopulate( + // Add temporary actions to post so that UI updates immeadiately + async doAddTemporaryPostAction( { commit, getters }, - { keepPagination = false } + { index, storeActionType, action } ) { - try { - commit('POPULATE_STARTED', { - keepPagination - }) - const keywords = - getters.activeView.filter.attributes.keywords.value + const activeView = getters.activeView.id - await EagleEyeService.populate(keywords) + // Add new action + if (storeActionType === 'add') { + commit('CREATE_TEMPORARY_ACTION', { + action, + activeView, + index + }) + // Remove action + } else { + commit('REMOVE_TEMPORARY_ACTION', { + action, + activeView, + index + }) + } + }, - commit('POPULATE_SUCCESS', { - keywords + // Add or remove actions from the database depending on the action type + async doUpdatePostAction( + { state, dispatch, getters }, + { post, index, storeActionType, actionType } + ) { + const activeView = getters.activeView.id + const action = state.views[activeView].list.posts[ + index + ].actions.find((a) => a.type === actionType) || { + type: actionType, + timestamp: moment() + } + // Add new action + if (storeActionType === 'add') { + await dispatch('doAddAction', { + post, + action, + index + }) + // Remove action + } else { + await dispatch('doRemoveAction', { + postId: post.id, + action, + index }) - } catch (error) { - Errors.handle(error) - commit('POPULATE_ERROR') } }, - async doExclude({ commit }, recordId) { - try { - commit('EXCLUDE_STARTED', recordId) + async doAddAction( + { state, commit, getters, rootGetters }, + { post, action, index } + ) { + const activeView = getters.activeView.id + const oppositeActionTypes = { + ['thumbs-up']: 'thumbs-down', + ['thumbs-down']: 'thumbs-up' + } + const oppositeAction = state.views[ + activeView + ].list.posts[index].actions.find( + (a) => a.type === oppositeActionTypes[action.type] + ) - await EagleEyeService.exclude(recordId) + // If action is thumbs, delete opposite thumbs from post + if ( + oppositeActionTypes[action.type] && + oppositeAction + ) { + commit('REMOVE_TEMPORARY_ACTION', { + action: oppositeAction, + activeView, + index + }) - commit('EXCLUDE_SUCCESS', recordId) - } catch (error) { - Errors.handle(error) - commit('EXCLUDE_ERROR') + await EagleEyeService.deleteAction({ + postId: post.id, + actionId: oppositeAction.id + }) } + + const postDb = await EagleEyeService.createContent({ + post: { + actions: [], + platform: post.platform, + post: post.post, + postedAt: post.postedAt, + url: post.url + } + }) + + const actionDb = await EagleEyeService.addAction({ + postId: postDb.id, + action + }) + + commit('CREATE_ACTION_SUCCESS', { + post: postDb, + action: actionDb, + index, + activeView + }) + + // Update posts with new actions in local storage + const currentUser = rootGetters['auth/currentUser'] + const currentTenant = rootGetters['auth/currentTenant'] + + setResultsInStorage({ + posts: state.views['feed'].list.posts, + storageDate: moment(), + tenantId: currentTenant.id, + userId: currentUser.id + }) }, - async doRevertExclude({ commit }, recordId) { - try { - commit('REVERT_EXCLUDE_STARTED', recordId) + async doRemoveAction( + { state, commit, getters, rootGetters }, + { postId, action, index } + ) { + const activeView = getters.activeView.id + const actionId = action.id + const deleteImmeadiately = + activeView === 'bookmarked' && + action.type === 'bookmark' - await EagleEyeService.revertExclude(recordId) + if (deleteImmeadiately) { + commit('REMOVE_ACTION_SUCCESS', { + postId, + action, + index, + activeView + }) + } - commit('REVERT_EXCLUDE_SUCCESS', recordId) - } catch (error) { - Errors.handle(error) - commit('REVERT_EXCLUDE_ERROR') + await EagleEyeService.deleteAction({ + postId, + actionId + }) + + if (!deleteImmeadiately) { + commit('REMOVE_ACTION_SUCCESS', { + postId, + action, + index, + activeView + }) } + + // Update posts with new actions in local storage + const currentUser = rootGetters['auth/currentUser'] + const currentTenant = rootGetters['auth/currentTenant'] + + setResultsInStorage({ + posts: state.views['feed'].list.posts, + storageDate: moment(), + tenantId: currentTenant.id, + userId: currentUser.id + }) }, - async doEngage({ commit }, recordId) { - try { - commit('ENGAGE_STARTED', recordId) + doAddActionQueue({ commit, state, dispatch }, job) { + commit('ADD_PENDING_ACTION', job) - await EagleEyeService.engage(recordId) + // If there are no actions active, start the next one in the queue + if (Object.keys(state.activeAction).length == 0) { + dispatch('doStartActionQueue') + } + }, - commit('ENGAGE_SUCCESS', recordId) - } catch (error) { - Errors.handle(error) - commit('ENGAGE_ERROR') + async doStartActionQueue({ + commit, + dispatch, + state, + getters, + rootGetters + }) { + if (state.pendingActions.length > 0) { + commit('SET_ACTIVE_ACTION', state.pendingActions[0]) + + const pendingAction = { ...state.pendingActions[0] } + + commit('POP_CURRENT_ACTION') + + try { + await pendingAction.handler() + await dispatch('doStartActionQueue') + } catch (error) { + // In case of an error, create post again and update it in UI + EagleEyeService.createContent({ + post: pendingAction.post + }).then((response) => { + const activeView = getters.activeView.id + const currentUser = + rootGetters['auth/currentUser'] + const currentTenant = + rootGetters['auth/currentTenant'] + + commit('UPDATE_POST', { + activeView, + index: pendingAction.index, + post: response + }) + + // Update posts with new actions in local storage + setResultsInStorage({ + posts: state.views['feed'].list.posts, + storageDate: moment(), + tenantId: currentTenant.id, + userId: currentUser.id + }) + }) + + Message.error( + 'Something went wrong. Please try again' + ) + commit('SET_ACTIVE_ACTION', {}) + } } + + commit('SET_ACTIVE_ACTION', {}) + }, + + async doUpdateSettings( + { commit, dispatch }, + { data, fetchNewResults = true } + ) { + commit('UPDATE_EAGLE_EYE_SETTINGS_STARTED') + return EagleEyeService.updateSettings(data) + .then(() => { + dispatch(`auth/doRefreshCurrentUser`, null, { + root: true + }).then(() => { + commit('UPDATE_EAGLE_EYE_SETTINGS_SUCCESS') + + if (fetchNewResults) { + dispatch(`doFetch`, { + resetStorage: true + }) + } + return Promise.resolve() + }) + }) + .catch((error) => { + Errors.handle(error) + commit('UPDATE_EAGLE_EYE_SETTINGS_ERROR') + return Promise.reject() + }) } } diff --git a/frontend/src/premium/eagle-eye/store/getters.js b/frontend/src/premium/eagle-eye/store/getters.js index 7cb7d233c0..b52596f2df 100644 --- a/frontend/src/premium/eagle-eye/store/getters.js +++ b/frontend/src/premium/eagle-eye/store/getters.js @@ -1,14 +1,42 @@ import sharedGetters from '@/shared/store/getters' +import { INITIAL_PAGE_SIZE } from './constants' export default { ...sharedGetters(), - rows: (state, getters) => { - return state.list.ids - .map((r) => state.records[r]) - .filter((r) => { - return getters.activeView.id === 'inbox' - ? r.status === null - : r.status === getters.activeView.id - }) + + activeViewList: (state) => { + const activeView = sharedGetters().activeView(state) + + return state.views[activeView.id].list + }, + + pagination: (state, getters) => { + return { + ...getters.activeView.pagination, + total: getters.activeView.count, + showSizeChanger: true + } + }, + + limit: (state, getters) => { + const { pagination } = getters.activeView + + if (!pagination?.pageSize) { + return INITIAL_PAGE_SIZE + } + + return pagination.pageSize + }, + + offset: (state, getters) => { + const { pagination } = getters.activeView + + if (!pagination?.pageSize) { + return 0 + } + + const { currentPage = 1 } = pagination + + return (currentPage - 1) * pagination.pageSize } } diff --git a/frontend/src/premium/eagle-eye/store/mutations.js b/frontend/src/premium/eagle-eye/store/mutations.js index 71ccae9b85..a1d399bb2a 100644 --- a/frontend/src/premium/eagle-eye/store/mutations.js +++ b/frontend/src/premium/eagle-eye/store/mutations.js @@ -2,58 +2,191 @@ import sharedMutations from '@/shared/store/mutations' export default { ...sharedMutations(), - FETCH_SUCCESS(state, { rows, count }) { - state.list.loading = false + FETCH_STARTED(state, { keepPagination, activeView }) { + state.views[activeView].list.loading = true - for (const record of rows) { - state.records[record.id] = record - if (!state.list.ids.includes(record.id)) { - state.list.ids.push(record.id) + if (!keepPagination) { + state.views[activeView].list.posts.length = 0 + } + + state.pagination = keepPagination + ? state.pagination + : { + currentPage: 1, + pageSize: + state.pagination && state.pagination.pageSize + } + }, + + FETCH_SUCCESS( + state, + { list, count, appendToList, activeView } + ) { + state.views[activeView].list.loading = false + if (appendToList) { + state.views[activeView].list.posts.push(...list) + } else { + state.views[activeView].list.posts = list + } + state.views[activeView].count = count + }, + + FETCH_ERROR(state, { activeView }) { + state.views[activeView].list.loading = false + state.views[activeView].list.posts = [] + state.views[activeView].count = 0 + }, + + CREATE_ACTION_SUCCESS( + state, + { post, action, index, activeView } + ) { + // Update feed post if bookmark action is updated + if (activeView === 'bookmarked') { + const feedPost = state.views['feed'].list.posts.find( + (p) => p.url === post.url + ) + + if (feedPost) { + const indexAction = feedPost.actions.findIndex( + (a) => a.type === action.type + ) + + if (indexAction === -1) { + feedPost.actions.push(action) + } else { + feedPost.actions[indexAction] = { + ...action, + id: action.id + } + } + } + } + + const { actions } = + state.views[activeView].list.posts[index] + const indexAction = actions.findIndex( + (a) => a.type === action.type + ) + + // Update store post with new one, except for actions + state.views[activeView].list.posts[index] = { + ...post, + actions + } + + if (indexAction === -1) { + actions.push(action) + } else { + actions[indexAction] = { + ...action, + id: action.id } } + }, + + REMOVE_ACTION_SUCCESS( + state, + { postId, action, index, activeView } + ) { + // Update feed post if bookmark action is updated + if (activeView === 'bookmarked') { + const feedPost = state.views['feed'].list.posts.find( + (p) => p.id === postId + ) - state.count = count + if (feedPost) { + const deleteIndex = feedPost.actions.findIndex( + (a) => a.type === action.type + ) + + if (deleteIndex !== -1) { + feedPost.actions.splice(deleteIndex, 1) + console.log(feedPost.actions) + } + } + } + + // Remove post from bookmarks view + if ( + action.type === 'bookmark' && + activeView === 'bookmarked' + ) { + state.views[activeView].list.posts.splice(index, 1) + } else { + // Remove action from post + const { actions } = + state.views[activeView].list.posts[index] + const deleteIndex = actions.findIndex( + (a) => a.type === action.type + ) + + if (deleteIndex !== -1) { + actions.splice(deleteIndex, 1) + } + } }, - POPULATE_STARTED(state, { keepPagination }) { - state.list.loading = true + CREATE_TEMPORARY_ACTION( + state, + { action, activeView, index } + ) { + const { actions } = + state.views[activeView].list.posts[index] + const indexAction = actions.findIndex( + (a) => a.type === action.type + ) - if (!keepPagination) { - state.list.ids.length = 0 + if (indexAction === -1) { + actions.push(action) + } else { + actions[indexAction] = action } }, - POPULATE_SUCCESS(state) { - state.list.loading = true + REMOVE_TEMPORARY_ACTION( + state, + { action, activeView, index } + ) { + const { actions } = + state.views[activeView].list.posts[index] + const deleteIndex = actions.findIndex( + (a) => a.type === action.type + ) + + actions[deleteIndex].toRemove = true }, - POPULATE_ERROR(state) { - state.list.loading = false + UPDATE_POST(state, { activeView, index, post }) { + state.views[activeView].list.posts[index] = post }, - ENGAGE_STARTED() {}, + SORTER_CHANGED(state, payload) { + const { activeView, sorter } = payload + state.views[activeView.id].sorter = sorter + }, - ENGAGE_SUCCESS(state, recordId) { - if (state.records[recordId].status !== 'engaged') { - state.count-- - } - state.records[recordId].status = 'engaged' + UPDATE_EAGLE_EYE_SETTINGS_STARTED(state) { + state.loadingUpdateSettings = true }, - ENGAGE_ERROR() {}, - EXCLUDE_STARTED() {}, + UPDATE_EAGLE_EYE_SETTINGS_SUCCESS(state) { + state.loadingUpdateSettings = false + }, - EXCLUDE_SUCCESS(state, recordId) { - state.records[recordId].status = 'rejected' - state.count-- + UPDATE_EAGLE_EYE_SETTINGS_ERROR(state) { + state.loadingUpdateSettings = false }, - EXCLUDE_ERROR() {}, - REVERT_EXCLUDE_STARTED() {}, + ADD_PENDING_ACTION(state, job) { + state.pendingActions.push(job) + }, - REVERT_EXCLUDE_SUCCESS(state, recordId) { - state.records[recordId].status = null - state.count-- + SET_ACTIVE_ACTION(state, job) { + state.activeAction = job }, - REVERT_EXCLUDE_ERROR() {} + + POP_CURRENT_ACTION(state) { + state.pendingActions.shift() + } } diff --git a/frontend/src/premium/eagle-eye/store/state.js b/frontend/src/premium/eagle-eye/store/state.js index dbe210cf10..8e92f374b2 100644 --- a/frontend/src/premium/eagle-eye/store/state.js +++ b/frontend/src/premium/eagle-eye/store/state.js @@ -1,156 +1,37 @@ import { INITIAL_PAGE_SIZE } from './constants' -const savedKeywords = localStorage.getItem( - 'eagleEye_keywords' -) - -const savedKeywordsArray = - savedKeywords && savedKeywords !== '' - ? savedKeywords.split(',') - : [] - export default () => { return { records: {}, views: { - inbox: { - id: 'inbox', - label: 'Inbox', - initialFilter: { - operator: 'and', - attributes: { - keywords: { - name: 'keywords', - label: 'Keywords', - show: false, - operator: 'overlap', - defaultOperator: 'overlap', - type: 'custom', - value: savedKeywordsArray, - defaultValue: savedKeywordsArray - } - } - }, - filter: { - operator: 'and', - attributes: { - keywords: { - name: 'keywords', - label: 'Keywords', - show: false, - operator: 'overlap', - defaultOperator: 'overlap', - type: 'custom', - value: savedKeywordsArray, - defaultValue: savedKeywordsArray - } - } - }, - pagination: { - currentPage: 1, - pageSize: INITIAL_PAGE_SIZE - }, - initialSorter: { - prop: 'similarityScore', - order: 'descending' - }, - sorter: { - prop: 'similarityScore', - order: 'descending' - }, + feed: { + id: 'feed', + label: 'Feed', + list: { + posts: [], + loading: false + }, + count: 0, active: true }, - engaged: { - id: 'engaged', - label: 'Engaged', - initialFilter: { - operator: 'and', - attributes: { - status: { - name: 'status', - operator: 'eq', - defaultOperator: 'eq', - defaultValue: 'engaged', - value: 'engaged', - show: false - } - } - }, - filter: { - operator: 'and', - attributes: { - status: { - name: 'status', - operator: 'eq', - defaultOperator: 'eq', - defaultValue: 'engaged', - value: 'engaged', - show: false - } - } - }, + bookmarked: { + id: 'bookmarked', + label: 'Bookmarked', + list: { + posts: [], + loading: false + }, + count: 0, pagination: { currentPage: 1, pageSize: INITIAL_PAGE_SIZE }, - initialSorter: { - prop: 'similarityScore', - order: 'descending' - }, - sorter: { - prop: 'similarityScore', - order: 'descending' - }, - active: false - }, - rejected: { - id: 'rejected', - label: 'Excluded', - initialFilter: { - operator: 'and', - attributes: { - status: { - name: 'status', - operator: 'eq', - defaultOperator: 'eq', - defaultValue: 'rejected', - value: 'rejected', - show: false - } - } - }, - filter: { - operator: 'and', - attributes: { - status: { - name: 'status', - operator: 'eq', - defaultOperator: 'eq', - defaultValue: 'rejected', - value: 'rejected', - show: false - } - } - }, - pagination: { - currentPage: 1, - pageSize: INITIAL_PAGE_SIZE - }, - initialSorter: { - prop: 'similarityScore', - order: 'descending' - }, - sorter: { - prop: 'similarityScore', - order: 'descending' - }, + sorter: 'individualBookmarks', active: false } }, - list: { - ids: [], - loading: false - }, - count: 0 + pendingActions: [], + activeAction: {}, + loadingUpdateSettings: false } } diff --git a/frontend/src/shared/dialog/dialog.vue b/frontend/src/shared/dialog/dialog.vue index 5c1e5dc025..c2ab8abd22 100644 --- a/frontend/src/shared/dialog/dialog.vue +++ b/frontend/src/shared/dialog/dialog.vue @@ -81,7 +81,7 @@ const props = defineProps({ default: () => null }, title: { - type: [String, Node], + type: [String, Node, Object], required: true }, size: { diff --git a/frontend/src/shared/drawer/drawer.vue b/frontend/src/shared/drawer/drawer.vue index 12d8472519..bb51f10707 100644 --- a/frontend/src/shared/drawer/drawer.vue +++ b/frontend/src/shared/drawer/drawer.vue @@ -20,12 +20,7 @@ {{ preTitle }}
- +
true }, - preTitleImgSrc: { - type: String, - default: () => null - }, - preTitleImgAlt: { - type: String, - default: () => null - }, hasBorder: { type: Boolean, default: () => false diff --git a/frontend/src/shared/form/element-change.js b/frontend/src/shared/form/element-change.js new file mode 100644 index 0000000000..48c6a6408a --- /dev/null +++ b/frontend/src/shared/form/element-change.js @@ -0,0 +1,22 @@ +import { ref, computed } from 'vue' + +export default function elementChangeDetector(element) { + const temporaryElement = ref('') + + function elementSnapshot() { + temporaryElement.value = JSON.stringify(element.value) + } + + const hasElementChanged = computed(() => { + return ( + temporaryElement.value !== + JSON.stringify(element.value) + ) + }) + + return { + temporaryElement, + elementSnapshot, + hasElementChanged + } +} diff --git a/frontend/src/shared/form/form-change.js b/frontend/src/shared/form/form-change.js new file mode 100644 index 0000000000..b159178e9e --- /dev/null +++ b/frontend/src/shared/form/form-change.js @@ -0,0 +1,19 @@ +import { ref, computed } from 'vue' + +export default function formChangeDetector(form) { + const temporaryForm = ref('') + + function formSnapshot() { + temporaryForm.value = JSON.stringify(form) + } + + const hasFormChanged = computed(() => { + return temporaryForm.value !== JSON.stringify(form) + }) + + return { + temporaryForm, + formSnapshot, + hasFormChanged + } +} diff --git a/frontend/src/shared/form/form-item.vue b/frontend/src/shared/form/form-item.vue new file mode 100644 index 0000000000..ce6a022b26 --- /dev/null +++ b/frontend/src/shared/form/form-item.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/shared/form/inline-select-input.vue b/frontend/src/shared/form/inline-select-input.vue index aefd0212b9..3c426f7103 100644 --- a/frontend/src/shared/form/inline-select-input.vue +++ b/frontend/src/shared/form/inline-select-input.vue @@ -26,10 +26,21 @@ - {{ option.label }} +
+ {{ option.label }} + + {{ option.description }} + +
diff --git a/frontend/src/shared/image/image.vue b/frontend/src/shared/image/image.vue new file mode 100644 index 0000000000..7419ab11a4 --- /dev/null +++ b/frontend/src/shared/image/image.vue @@ -0,0 +1,35 @@ + + + + + + + diff --git a/frontend/src/shared/shared-module.js b/frontend/src/shared/shared-module.js index 7bb593e9ee..ec90ae0ff1 100644 --- a/frontend/src/shared/shared-module.js +++ b/frontend/src/shared/shared-module.js @@ -46,6 +46,7 @@ import Platform from '@/shared/platform/platform' import Drawer from '@/shared/drawer/drawer' import AppLoader from '@/shared/loading/loader' import AppPageWrapper from '@/shared/layout/page-wrapper' +import Image from '@/shared/image/image' /** * All shared components are globally registered, so there's no need to import them from other components @@ -101,6 +102,7 @@ export default { 'app-platform': Platform, 'app-drawer': Drawer, 'app-loader': AppLoader, - 'app-page-wrapper': AppPageWrapper + 'app-page-wrapper': AppPageWrapper, + 'app-image': Image } } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 5fc3cb2f4f..1268b7a783 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1107,5 +1107,5 @@ module.exports = { 'active', 'disabled' ], - plugins: [] + plugins: [require('@tailwindcss/line-clamp')] }