mirror of
https://github.com/TryGhost/Ghost.git
synced 2023-12-13 21:00:40 +01:00
Added an option to recommend back in Admin Settings (#18478)
refs https://github.com/TryGhost/Product/issues/3978 - added "GET /incoming_recommendations/" browse endpoint to the Admin API - we store incoming recommendations as mentions in the database. The new endpoint reuses the Mentions API underneath to fetch verified mentions of type recommendation - recommendation-specific attributes are returned by the new endpoint, including calculated fields such as the "RecommendingBack" boolean - show "Recommend back" option for sites recommending me, only if I haven't recommended the site already
This commit is contained in:
parent
e06a5825dc
commit
15adb254f0
16 changed files with 198 additions and 97 deletions
|
@ -1,42 +0,0 @@
|
||||||
import {InfiniteData} from '@tanstack/react-query';
|
|
||||||
import {Meta, createInfiniteQuery} from '../utils/api/hooks';
|
|
||||||
|
|
||||||
export type Mention = {
|
|
||||||
id: string;
|
|
||||||
source: string;
|
|
||||||
source_title: string|null;
|
|
||||||
source_site_title: string|null;
|
|
||||||
source_excerpt: string|null;
|
|
||||||
source_author: string|null;
|
|
||||||
source_featured_image: string|null;
|
|
||||||
source_favicon: string|null;
|
|
||||||
target: string;
|
|
||||||
verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MentionsResponseType {
|
|
||||||
meta?: Meta
|
|
||||||
mentions: Mention[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataType = 'MentionsResponseType';
|
|
||||||
|
|
||||||
export const useBrowseMentions = createInfiniteQuery<MentionsResponseType>({
|
|
||||||
dataType,
|
|
||||||
path: '/mentions/',
|
|
||||||
returnData: (originalData) => {
|
|
||||||
const {pages} = originalData as InfiniteData<MentionsResponseType>;
|
|
||||||
let mentions = pages.flatMap(page => page.mentions);
|
|
||||||
|
|
||||||
// Remove duplicates
|
|
||||||
mentions = mentions.filter((mention, index) => {
|
|
||||||
return mentions.findIndex(({id}) => id === mention.id) === index;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentions,
|
|
||||||
meta: pages[pages.length - 1].meta
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -99,3 +99,37 @@ export const useGetRecommendationByUrl = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IncomingRecommendation = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
excerpt: string|null
|
||||||
|
featured_image: string|null
|
||||||
|
favicon: string|null
|
||||||
|
recommending_back: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomingRecommendationResponseType {
|
||||||
|
meta?: Meta
|
||||||
|
recommendations: IncomingRecommendation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBrowseIncomingRecommendations = createInfiniteQuery<IncomingRecommendationResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/incoming_recommendations/',
|
||||||
|
returnData: (originalData) => {
|
||||||
|
const {pages} = originalData as InfiniteData<IncomingRecommendationResponseType>;
|
||||||
|
let recommendations = pages.flatMap(page => page.recommendations);
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
recommendations = recommendations.filter((mention, index) => {
|
||||||
|
return recommendations.findIndex(({id}) => id === mention.id) === index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendations,
|
||||||
|
meta: pages[pages.length - 1].meta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -7,8 +7,7 @@ import TabView from '../../../admin-x-ds/global/TabView';
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
|
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
|
||||||
import {useBrowseMentions} from '../../../api/mentions';
|
import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '../../../api/recommendations';
|
||||||
import {useBrowseRecommendations} from '../../../api/recommendations';
|
|
||||||
import {useReferrerHistory} from '../../../api/referrers';
|
import {useReferrerHistory} from '../../../api/referrers';
|
||||||
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
||||||
|
|
||||||
|
@ -52,11 +51,10 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
loadMore: fetchNextPage
|
loadMore: fetchNextPage
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch "Recommending you" (mentions & stats)
|
// Fetch "Recommending you", including stats
|
||||||
const {data: {mentions, meta: mentionsMeta} = {}, isLoading: areMentionsLoading, hasNextPage: hasMentionsNextPage, fetchNextPage: fetchMentionsNextPage} = useBrowseMentions({
|
const {data: {recommendations: incomingRecommendations, meta: incomingRecommendationsMeta} = {}, isLoading: areIncomingRecommendationsLoading, hasNextPage: hasIncomingRecommendationsNextPage, fetchNextPage: fetchIncomingRecommendationsNextPage} = useBrowseIncomingRecommendations({
|
||||||
searchParams: {
|
searchParams: {
|
||||||
limit: '5',
|
limit: '5',
|
||||||
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
|
|
||||||
order: 'created_at desc'
|
order: 'created_at desc'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -81,12 +79,12 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
keepPreviousData: true
|
keepPreviousData: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const showMoreMentions: ShowMoreData = {
|
const {data: {stats} = {}, isLoading: areStatsLoading} = useReferrerHistory({});
|
||||||
hasMore: !!hasMentionsNextPage,
|
|
||||||
loadMore: fetchMentionsNextPage
|
|
||||||
};
|
|
||||||
|
|
||||||
const {data: {stats: mentionsStats} = {}, isLoading: areSourcesLoading} = useReferrerHistory({});
|
const showMoreMentions: ShowMoreData = {
|
||||||
|
hasMore: !!hasIncomingRecommendationsNextPage,
|
||||||
|
loadMore: fetchIncomingRecommendationsNextPage
|
||||||
|
};
|
||||||
|
|
||||||
// Select "Your recommendations" by default
|
// Select "Your recommendations" by default
|
||||||
const [selectedTab, setSelectedTab] = useState('your-recommendations');
|
const [selectedTab, setSelectedTab] = useState('your-recommendations');
|
||||||
|
@ -101,8 +99,8 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
{
|
{
|
||||||
id: 'recommending-you',
|
id: 'recommending-you',
|
||||||
title: `Recommending you`,
|
title: `Recommending you`,
|
||||||
counter: mentionsMeta?.pagination?.total,
|
counter: incomingRecommendationsMeta?.pagination?.total,
|
||||||
contents: <IncomingRecommendationList isLoading={areMentionsLoading || areSourcesLoading} mentions={mentions ?? []} showMore={showMoreMentions} stats={mentionsStats ?? []}/>
|
contents: <IncomingRecommendationList incomingRecommendations={incomingRecommendations ?? []} isLoading={areIncomingRecommendationsLoading || areStatsLoading} showMore={showMoreMentions} stats={stats ?? []}/>
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -6,26 +6,26 @@ import Table, {ShowMoreData} from '../../../../admin-x-ds/global/Table';
|
||||||
import TableCell from '../../../../admin-x-ds/global/TableCell';
|
import TableCell from '../../../../admin-x-ds/global/TableCell';
|
||||||
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||||
import useRouting from '../../../../hooks/useRouting';
|
import useRouting from '../../../../hooks/useRouting';
|
||||||
import {Mention} from '../../../../api/mentions';
|
import {IncomingRecommendation} from '../../../../api/recommendations';
|
||||||
import {PaginationData} from '../../../../hooks/usePagination';
|
import {PaginationData} from '../../../../hooks/usePagination';
|
||||||
import {ReferrerHistoryItem} from '../../../../api/referrers';
|
import {ReferrerHistoryItem} from '../../../../api/referrers';
|
||||||
|
|
||||||
interface IncomingRecommendationListProps {
|
interface IncomingRecommendationListProps {
|
||||||
mentions: Mention[],
|
incomingRecommendations: IncomingRecommendation[],
|
||||||
stats: ReferrerHistoryItem[],
|
stats: ReferrerHistoryItem[],
|
||||||
pagination?: PaginationData,
|
pagination?: PaginationData,
|
||||||
showMore?: ShowMoreData,
|
showMore?: ShowMoreData,
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHistoryItem[]}> = ({mention, stats}) => {
|
const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => {
|
||||||
const cleanedSource = mention.source.replace('/.well-known/recommendations.json', '');
|
const {updateRoute} = useRouting();
|
||||||
|
|
||||||
const signups = useMemo(() => {
|
const signups = useMemo(() => {
|
||||||
// Note: this should match the `getDomainFromUrl` method from OutboundLinkTagger
|
// Note: this should match the `getDomainFromUrl` method from OutboundLinkTagger
|
||||||
let cleanedDomain = cleanedSource;
|
let cleanedDomain = incomingRecommendation.url;
|
||||||
try {
|
try {
|
||||||
cleanedDomain = new URL(cleanedSource).hostname.replace(/^www\./, '');
|
cleanedDomain = new URL(incomingRecommendation.url).hostname.replace(/^www\./, '');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore invalid urls
|
// Ignore invalid urls
|
||||||
}
|
}
|
||||||
|
@ -36,33 +36,33 @@ const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHis
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [stats, cleanedSource]);
|
}, [stats, incomingRecommendation.url]);
|
||||||
|
|
||||||
const showDetails = () => {
|
const recommendBack = () => {
|
||||||
// Open url
|
updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`});
|
||||||
window.open(cleanedSource, '_blank');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const freeMembersLabel = (signups) === 1 ? 'free member' : 'free members';
|
const showDetails = () => {
|
||||||
|
window.open(incomingRecommendation.url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
const {updateRoute} = useRouting();
|
const freeMembersLabel = signups === 1 ? 'free member' : 'free members';
|
||||||
|
|
||||||
const action = (
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<Button color='green' label='Recommend back' size='sm' link onClick={() => {
|
|
||||||
updateRoute({route: `recommendations/add?url=${cleanedSource}`});
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow action={action} hideActions>
|
<TableRow action={
|
||||||
|
!incomingRecommendation.recommending_back && (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button color='green' label='Recommend back' size='sm' link onClick={recommendBack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} hideActions>
|
||||||
<TableCell onClick={showDetails}>
|
<TableCell onClick={showDetails}>
|
||||||
<div className='group flex items-center gap-3 hover:cursor-pointer'>
|
<div className='group flex items-center gap-3 hover:cursor-pointer'>
|
||||||
<div className={`flex grow flex-col`}>
|
<div className={`flex grow flex-col`}>
|
||||||
<div className="mb-0.5 flex items-center gap-3">
|
<div className="mb-0.5 flex items-center gap-3">
|
||||||
<RecommendationIcon favicon={mention.source_favicon} featured_image={mention.source_featured_image} title={mention.source_title || mention.source_site_title || cleanedSource} />
|
<RecommendationIcon favicon={incomingRecommendation.favicon} featured_image={incomingRecommendation.featured_image} title={incomingRecommendation.title || incomingRecommendation.url} />
|
||||||
<span className='line-clamp-1'>{mention.source_title || mention.source_site_title || cleanedSource}</span>
|
<span className='line-clamp-1'>{incomingRecommendation.title || incomingRecommendation.url}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,10 +74,10 @@ const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHis
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({mentions, stats, pagination, showMore, isLoading}) => {
|
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({incomingRecommendations, stats, pagination, showMore, isLoading}) => {
|
||||||
if (isLoading || mentions.length) {
|
if (isLoading || incomingRecommendations.length) {
|
||||||
return <Table isLoading={isLoading} pagination={pagination} showMore={showMore} hintSeparator>
|
return <Table isLoading={isLoading} pagination={pagination} showMore={showMore} hintSeparator>
|
||||||
{mentions.map(mention => <IncomingRecommendationItem key={mention.id} mention={mention} stats={stats} />)}
|
{incomingRecommendations.map(rec => <IncomingRecommendationItem key={rec.id} incomingRecommendation={rec} stats={stats} />)}
|
||||||
</Table>;
|
</Table>;
|
||||||
} else {
|
} else {
|
||||||
return <NoValueLabel>
|
return <NoValueLabel>
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
const recommendations = require('../../services/recommendations');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
docName: 'recommendations',
|
||||||
|
|
||||||
|
browse: {
|
||||||
|
headers: {
|
||||||
|
cacheInvalidate: false
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
'limit',
|
||||||
|
'page'
|
||||||
|
],
|
||||||
|
permissions: true,
|
||||||
|
validation: {},
|
||||||
|
async query(frame) {
|
||||||
|
return await recommendations.incomingRecommendationController.browse(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -209,6 +209,10 @@ module.exports = {
|
||||||
return apiFramework.pipeline(require('./recommendations'), localUtils);
|
return apiFramework.pipeline(require('./recommendations'), localUtils);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get incomingRecommendations() {
|
||||||
|
return apiFramework.pipeline(require('./incoming-recommendations'), localUtils);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content API Controllers
|
* Content API Controllers
|
||||||
*
|
*
|
||||||
|
|
|
@ -28,6 +28,11 @@ class RecommendationServiceWrapper {
|
||||||
*/
|
*/
|
||||||
service;
|
service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('@tryghost/recommendations').IncomingRecommendationController}
|
||||||
|
*/
|
||||||
|
incomingRecommendationController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
|
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
|
||||||
*/
|
*/
|
||||||
|
@ -51,6 +56,7 @@ class RecommendationServiceWrapper {
|
||||||
RecommendationController,
|
RecommendationController,
|
||||||
WellknownService,
|
WellknownService,
|
||||||
BookshelfClickEventRepository,
|
BookshelfClickEventRepository,
|
||||||
|
IncomingRecommendationController,
|
||||||
IncomingRecommendationService,
|
IncomingRecommendationService,
|
||||||
IncomingRecommendationEmailRenderer
|
IncomingRecommendationEmailRenderer
|
||||||
} = require('@tryghost/recommendations');
|
} = require('@tryghost/recommendations');
|
||||||
|
@ -125,6 +131,10 @@ class RecommendationServiceWrapper {
|
||||||
service: this.service
|
service: this.service
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.incomingRecommendationController = new IncomingRecommendationController({
|
||||||
|
service: this.incomingRecommendationService
|
||||||
|
});
|
||||||
|
|
||||||
if (labs.isSet('recommendations')) {
|
if (labs.isSet('recommendations')) {
|
||||||
this.service.init().catch(logging.error);
|
this.service.init().catch(logging.error);
|
||||||
this.incomingRecommendationService.init().catch(logging.error);
|
this.incomingRecommendationService.init().catch(logging.error);
|
||||||
|
|
|
@ -354,5 +354,8 @@ module.exports = function apiRoutes() {
|
||||||
router.put('/recommendations/:id', mw.authAdminApi, http(api.recommendations.edit));
|
router.put('/recommendations/:id', mw.authAdminApi, http(api.recommendations.edit));
|
||||||
router.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy));
|
router.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy));
|
||||||
|
|
||||||
|
// Incoming recommendations
|
||||||
|
router.get('/incoming_recommendations', mw.authAdminApi, http(api.incomingRecommendations.browse));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:ts": "yarn build",
|
"build:ts": "yarn build",
|
||||||
"prepare": "tsc",
|
"prepare": "tsc",
|
||||||
"test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
|
"test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
|
||||||
"test": "yarn test:types && yarn test:unit",
|
"test": "yarn test:types && yarn test:unit",
|
||||||
"test:types": "tsc --noEmit",
|
"test:types": "tsc --noEmit",
|
||||||
"lint:code": "eslint src/ --ext .ts --cache",
|
"lint:code": "eslint src/ --ext .ts --cache",
|
||||||
|
|
9
ghost/recommendations/src/IncomingRecommendation.ts
Normal file
9
ghost/recommendations/src/IncomingRecommendation.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export type IncomingRecommendation = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: URL;
|
||||||
|
excerpt: string|null;
|
||||||
|
favicon: URL|null;
|
||||||
|
featuredImage: URL|null;
|
||||||
|
recommendingBack: boolean;
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {IncomingRecommendationService} from './IncomingRecommendationService';
|
||||||
|
import {IncomingRecommendation} from './IncomingRecommendation';
|
||||||
|
import {UnsafeData} from './UnsafeData';
|
||||||
|
|
||||||
|
type Frame = {
|
||||||
|
data: unknown,
|
||||||
|
options: unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Meta = {
|
||||||
|
pagination: object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomingRecommendationController {
|
||||||
|
service: IncomingRecommendationService;
|
||||||
|
|
||||||
|
constructor(deps: {service: IncomingRecommendationService}) {
|
||||||
|
this.service = deps.service;
|
||||||
|
}
|
||||||
|
|
||||||
|
async browse(frame: Frame) {
|
||||||
|
const options = new UnsafeData(frame.options);
|
||||||
|
|
||||||
|
const page = options.optionalKey('page')?.integer ?? 1;
|
||||||
|
const limit = options.optionalKey('limit')?.integer ?? 5;
|
||||||
|
const {incomingRecommendations, meta} = await this.service.listIncomingRecommendations({page, limit});
|
||||||
|
|
||||||
|
return this.#serialize(
|
||||||
|
incomingRecommendations,
|
||||||
|
meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#serialize(recommendations: IncomingRecommendation[], meta?: Meta) {
|
||||||
|
return {
|
||||||
|
data: recommendations.map((entity) => {
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
title: entity.title,
|
||||||
|
excerpt: entity.excerpt,
|
||||||
|
featured_image: entity.featuredImage?.toString() ?? null,
|
||||||
|
favicon: entity.favicon?.toString() ?? null,
|
||||||
|
url: entity.url.toString(),
|
||||||
|
recommending_back: !!entity.recommendingBack
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
meta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import {IncomingRecommendation, EmailRecipient} from './IncomingRecommendationService';
|
import {EmailRecipient} from './IncomingRecommendationService';
|
||||||
|
import {IncomingRecommendation} from './IncomingRecommendation';
|
||||||
|
|
||||||
type StaffService = {
|
type StaffService = {
|
||||||
api: {
|
api: {
|
||||||
|
@ -17,7 +18,7 @@ export class IncomingRecommendationEmailRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderSubject(recommendation: IncomingRecommendation) {
|
async renderSubject(recommendation: IncomingRecommendation) {
|
||||||
return `👍 New recommendation: ${recommendation.siteTitle}`;
|
return `👍 New recommendation: ${recommendation.title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderHTML(recommendation: IncomingRecommendation, recipient: EmailRecipient) {
|
async renderHTML(recommendation: IncomingRecommendation, recipient: EmailRecipient) {
|
||||||
|
|
|
@ -1,17 +1,8 @@
|
||||||
|
import {IncomingRecommendation} from './IncomingRecommendation';
|
||||||
import {IncomingRecommendationEmailRenderer} from './IncomingRecommendationEmailRenderer';
|
import {IncomingRecommendationEmailRenderer} from './IncomingRecommendationEmailRenderer';
|
||||||
import {RecommendationService} from './RecommendationService';
|
import {RecommendationService} from './RecommendationService';
|
||||||
import logging from '@tryghost/logging';
|
import logging from '@tryghost/logging';
|
||||||
|
|
||||||
export type IncomingRecommendation = {
|
|
||||||
title: string;
|
|
||||||
siteTitle: string|null;
|
|
||||||
url: URL;
|
|
||||||
excerpt: string|null;
|
|
||||||
favicon: URL|null;
|
|
||||||
featuredImage: URL|null;
|
|
||||||
recommendingBack: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Report = {
|
export type Report = {
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date,
|
endDate: Date,
|
||||||
|
@ -19,6 +10,7 @@ export type Report = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mention = {
|
type Mention = {
|
||||||
|
id: string,
|
||||||
source: URL,
|
source: URL,
|
||||||
sourceTitle: string,
|
sourceTitle: string,
|
||||||
sourceSiteTitle: string|null,
|
sourceSiteTitle: string|null,
|
||||||
|
@ -28,9 +20,13 @@ type Mention = {
|
||||||
sourceFeaturedImage: URL|null
|
sourceFeaturedImage: URL|null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MentionMeta = {
|
||||||
|
pagination: object,
|
||||||
|
}
|
||||||
|
|
||||||
type MentionsAPI = {
|
type MentionsAPI = {
|
||||||
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
|
refreshMentions(options: {filter: string, limit: number|'all'}): Promise<void>
|
||||||
listMentions(options: {filter: string, limit: number|'all'}): Promise<{data: Mention[]}>
|
listMentions(options: {filter: string, page: number, limit: number|'all'}): Promise<{data: Mention[], meta?: MentionMeta}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmailRecipient = {
|
export type EmailRecipient = {
|
||||||
|
@ -101,8 +97,8 @@ export class IncomingRecommendationService {
|
||||||
const recommendingBack = !!existing;
|
const recommendingBack = !!existing;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: mention.sourceTitle,
|
id: mention.id,
|
||||||
siteTitle: mention.sourceSiteTitle,
|
title: mention.sourceSiteTitle || mention.sourceTitle,
|
||||||
url,
|
url,
|
||||||
excerpt: mention.sourceExcerpt,
|
excerpt: mention.sourceExcerpt,
|
||||||
favicon: mention.sourceFavicon,
|
favicon: mention.sourceFavicon,
|
||||||
|
@ -130,4 +126,19 @@ export class IncomingRecommendationService {
|
||||||
await this.#emailService.send(recipient.email, subject, html, text);
|
await this.#emailService.send(recipient.email, subject, html, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listIncomingRecommendations(options: { page?: number; limit?: number|'all'}): Promise<{ incomingRecommendations: IncomingRecommendation[]; meta?: MentionMeta }> {
|
||||||
|
const page = options.page ?? 1;
|
||||||
|
const limit = options.limit ?? 5;
|
||||||
|
const filter = this.#getMentionFilter();
|
||||||
|
|
||||||
|
const mentions = await this.#mentionsApi.listMentions({filter, page, limit});
|
||||||
|
const mentionsToIncomingRecommendations = await Promise.all(mentions.data.map(mention => this.#mentionToIncomingRecommendation(mention)));
|
||||||
|
const incomingRecommendations = mentionsToIncomingRecommendations.filter((recommendation): recommendation is IncomingRecommendation => !!recommendation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
incomingRecommendations,
|
||||||
|
meta: mentions.meta
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,5 +9,6 @@ export * from './ClickEvent';
|
||||||
export * from './BookshelfClickEventRepository';
|
export * from './BookshelfClickEventRepository';
|
||||||
export * from './SubscribeEvent';
|
export * from './SubscribeEvent';
|
||||||
export * from './BookshelfSubscribeEventRepository';
|
export * from './BookshelfSubscribeEventRepository';
|
||||||
|
export * from './IncomingRecommendationController';
|
||||||
export * from './IncomingRecommendationService';
|
export * from './IncomingRecommendationService';
|
||||||
export * from './IncomingRecommendationEmailRenderer';
|
export * from './IncomingRecommendationEmailRenderer';
|
||||||
|
|
|
@ -76,6 +76,7 @@ describe('IncomingRecommendationService', function () {
|
||||||
describe('sendRecommendationEmail', function () {
|
describe('sendRecommendationEmail', function () {
|
||||||
it('should send email', async function () {
|
it('should send email', async function () {
|
||||||
await service.sendRecommendationEmail({
|
await service.sendRecommendationEmail({
|
||||||
|
id: 'test',
|
||||||
source: new URL('https://example.com'),
|
source: new URL('https://example.com'),
|
||||||
sourceTitle: 'Example',
|
sourceTitle: 'Example',
|
||||||
sourceSiteTitle: 'Example',
|
sourceSiteTitle: 'Example',
|
||||||
|
@ -90,6 +91,7 @@ describe('IncomingRecommendationService', function () {
|
||||||
it('ignores when mention not convertable to incoming recommendation', async function () {
|
it('ignores when mention not convertable to incoming recommendation', async function () {
|
||||||
readRecommendationByUrl.rejects(new Error('test'));
|
readRecommendationByUrl.rejects(new Error('test'));
|
||||||
await service.sendRecommendationEmail({
|
await service.sendRecommendationEmail({
|
||||||
|
id: 'test',
|
||||||
source: new URL('https://example.com'),
|
source: new URL('https://example.com'),
|
||||||
sourceTitle: 'Example',
|
sourceTitle: 'Example',
|
||||||
sourceSiteTitle: 'Example',
|
sourceSiteTitle: 'Example',
|
||||||
|
|
|
@ -3,7 +3,7 @@ module.exports = function (data) {
|
||||||
|
|
||||||
// Be careful when you indent the email, because whitespaces are visible in emails!
|
// Be careful when you indent the email, because whitespaces are visible in emails!
|
||||||
return `
|
return `
|
||||||
You have been recommended by ${recommendation.siteTitle || recommendation.title || recommendation.url}.
|
You have been recommended by ${recommendation.title || recommendation.url}.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue