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
@ -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 useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
|
||||
import {useBrowseMentions} from '../../../api/mentions';
|
||||
import {useBrowseRecommendations} from '../../../api/recommendations';
|
||||
import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '../../../api/recommendations';
|
||||
import {useReferrerHistory} from '../../../api/referrers';
|
||||
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
||||
|
||||
@ -52,11 +51,10 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
loadMore: fetchNextPage
|
||||
};
|
||||
|
||||
// Fetch "Recommending you" (mentions & stats)
|
||||
const {data: {mentions, meta: mentionsMeta} = {}, isLoading: areMentionsLoading, hasNextPage: hasMentionsNextPage, fetchNextPage: fetchMentionsNextPage} = useBrowseMentions({
|
||||
// Fetch "Recommending you", including stats
|
||||
const {data: {recommendations: incomingRecommendations, meta: incomingRecommendationsMeta} = {}, isLoading: areIncomingRecommendationsLoading, hasNextPage: hasIncomingRecommendationsNextPage, fetchNextPage: fetchIncomingRecommendationsNextPage} = useBrowseIncomingRecommendations({
|
||||
searchParams: {
|
||||
limit: '5',
|
||||
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
|
||||
order: 'created_at desc'
|
||||
},
|
||||
|
||||
@ -81,12 +79,12 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
keepPreviousData: true
|
||||
});
|
||||
|
||||
const showMoreMentions: ShowMoreData = {
|
||||
hasMore: !!hasMentionsNextPage,
|
||||
loadMore: fetchMentionsNextPage
|
||||
};
|
||||
const {data: {stats} = {}, isLoading: areStatsLoading} = useReferrerHistory({});
|
||||
|
||||
const {data: {stats: mentionsStats} = {}, isLoading: areSourcesLoading} = useReferrerHistory({});
|
||||
const showMoreMentions: ShowMoreData = {
|
||||
hasMore: !!hasIncomingRecommendationsNextPage,
|
||||
loadMore: fetchIncomingRecommendationsNextPage
|
||||
};
|
||||
|
||||
// Select "Your recommendations" by default
|
||||
const [selectedTab, setSelectedTab] = useState('your-recommendations');
|
||||
@ -101,8 +99,8 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
{
|
||||
id: 'recommending-you',
|
||||
title: `Recommending you`,
|
||||
counter: mentionsMeta?.pagination?.total,
|
||||
contents: <IncomingRecommendationList isLoading={areMentionsLoading || areSourcesLoading} mentions={mentions ?? []} showMore={showMoreMentions} stats={mentionsStats ?? []}/>
|
||||
counter: incomingRecommendationsMeta?.pagination?.total,
|
||||
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 TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {Mention} from '../../../../api/mentions';
|
||||
import {IncomingRecommendation} from '../../../../api/recommendations';
|
||||
import {PaginationData} from '../../../../hooks/usePagination';
|
||||
import {ReferrerHistoryItem} from '../../../../api/referrers';
|
||||
|
||||
interface IncomingRecommendationListProps {
|
||||
mentions: Mention[],
|
||||
incomingRecommendations: IncomingRecommendation[],
|
||||
stats: ReferrerHistoryItem[],
|
||||
pagination?: PaginationData,
|
||||
showMore?: ShowMoreData,
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHistoryItem[]}> = ({mention, stats}) => {
|
||||
const cleanedSource = mention.source.replace('/.well-known/recommendations.json', '');
|
||||
const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
const signups = useMemo(() => {
|
||||
// Note: this should match the `getDomainFromUrl` method from OutboundLinkTagger
|
||||
let cleanedDomain = cleanedSource;
|
||||
let cleanedDomain = incomingRecommendation.url;
|
||||
try {
|
||||
cleanedDomain = new URL(cleanedSource).hostname.replace(/^www\./, '');
|
||||
cleanedDomain = new URL(incomingRecommendation.url).hostname.replace(/^www\./, '');
|
||||
} catch (_) {
|
||||
// Ignore invalid urls
|
||||
}
|
||||
@ -36,33 +36,33 @@ const IncomingRecommendationItem: React.FC<{mention: Mention, stats: ReferrerHis
|
||||
}
|
||||
return s;
|
||||
}, 0);
|
||||
}, [stats, cleanedSource]);
|
||||
}, [stats, incomingRecommendation.url]);
|
||||
|
||||
const showDetails = () => {
|
||||
// Open url
|
||||
window.open(cleanedSource, '_blank');
|
||||
const recommendBack = () => {
|
||||
updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`});
|
||||
};
|
||||
|
||||
const freeMembersLabel = (signups) === 1 ? 'free member' : 'free members';
|
||||
const showDetails = () => {
|
||||
window.open(incomingRecommendation.url, '_blank');
|
||||
};
|
||||
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
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>
|
||||
);
|
||||
const freeMembersLabel = signups === 1 ? 'free member' : 'free members';
|
||||
|
||||
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}>
|
||||
<div className='group flex items-center gap-3 hover:cursor-pointer'>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<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} />
|
||||
<span className='line-clamp-1'>{mention.source_title || mention.source_site_title || cleanedSource}</span>
|
||||
<RecommendationIcon favicon={incomingRecommendation.favicon} featured_image={incomingRecommendation.featured_image} title={incomingRecommendation.title || incomingRecommendation.url} />
|
||||
<span className='line-clamp-1'>{incomingRecommendation.title || incomingRecommendation.url}</span>
|
||||
</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}) => {
|
||||
if (isLoading || mentions.length) {
|
||||
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({incomingRecommendations, stats, pagination, showMore, isLoading}) => {
|
||||
if (isLoading || incomingRecommendations.length) {
|
||||
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>;
|
||||
} else {
|
||||
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);
|
||||
},
|
||||
|
||||
get incomingRecommendations() {
|
||||
return apiFramework.pipeline(require('./incoming-recommendations'), localUtils);
|
||||
},
|
||||
|
||||
/**
|
||||
* Content API Controllers
|
||||
*
|
||||
|
@ -28,6 +28,11 @@ class RecommendationServiceWrapper {
|
||||
*/
|
||||
service;
|
||||
|
||||
/**
|
||||
* @type {import('@tryghost/recommendations').IncomingRecommendationController}
|
||||
*/
|
||||
incomingRecommendationController;
|
||||
|
||||
/**
|
||||
* @type {import('@tryghost/recommendations').IncomingRecommendationService}
|
||||
*/
|
||||
@ -51,6 +56,7 @@ class RecommendationServiceWrapper {
|
||||
RecommendationController,
|
||||
WellknownService,
|
||||
BookshelfClickEventRepository,
|
||||
IncomingRecommendationController,
|
||||
IncomingRecommendationService,
|
||||
IncomingRecommendationEmailRenderer
|
||||
} = require('@tryghost/recommendations');
|
||||
@ -125,6 +131,10 @@ class RecommendationServiceWrapper {
|
||||
service: this.service
|
||||
});
|
||||
|
||||
this.incomingRecommendationController = new IncomingRecommendationController({
|
||||
service: this.incomingRecommendationService
|
||||
});
|
||||
|
||||
if (labs.isSet('recommendations')) {
|
||||
this.service.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.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy));
|
||||
|
||||
// Incoming recommendations
|
||||
router.get('/incoming_recommendations', mw.authAdminApi, http(api.incomingRecommendations.browse));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
@ -11,7 +11,7 @@
|
||||
"build": "tsc",
|
||||
"build:ts": "yarn build",
|
||||
"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:types": "tsc --noEmit",
|
||||
"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 = {
|
||||
api: {
|
||||
@ -17,7 +18,7 @@ export class IncomingRecommendationEmailRenderer {
|
||||
}
|
||||
|
||||
async renderSubject(recommendation: IncomingRecommendation) {
|
||||
return `👍 New recommendation: ${recommendation.siteTitle}`;
|
||||
return `👍 New recommendation: ${recommendation.title}`;
|
||||
}
|
||||
|
||||
async renderHTML(recommendation: IncomingRecommendation, recipient: EmailRecipient) {
|
||||
|
@ -1,17 +1,8 @@
|
||||
import {IncomingRecommendation} from './IncomingRecommendation';
|
||||
import {IncomingRecommendationEmailRenderer} from './IncomingRecommendationEmailRenderer';
|
||||
import {RecommendationService} from './RecommendationService';
|
||||
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 = {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
@ -19,6 +10,7 @@ export type Report = {
|
||||
}
|
||||
|
||||
type Mention = {
|
||||
id: string,
|
||||
source: URL,
|
||||
sourceTitle: string,
|
||||
sourceSiteTitle: string|null,
|
||||
@ -28,9 +20,13 @@ type Mention = {
|
||||
sourceFeaturedImage: URL|null
|
||||
}
|
||||
|
||||
type MentionMeta = {
|
||||
pagination: object,
|
||||
}
|
||||
|
||||
type MentionsAPI = {
|
||||
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 = {
|
||||
@ -101,8 +97,8 @@ export class IncomingRecommendationService {
|
||||
const recommendingBack = !!existing;
|
||||
|
||||
return {
|
||||
title: mention.sourceTitle,
|
||||
siteTitle: mention.sourceSiteTitle,
|
||||
id: mention.id,
|
||||
title: mention.sourceSiteTitle || mention.sourceTitle,
|
||||
url,
|
||||
excerpt: mention.sourceExcerpt,
|
||||
favicon: mention.sourceFavicon,
|
||||
@ -130,4 +126,19 @@ export class IncomingRecommendationService {
|
||||
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 './SubscribeEvent';
|
||||
export * from './BookshelfSubscribeEventRepository';
|
||||
export * from './IncomingRecommendationController';
|
||||
export * from './IncomingRecommendationService';
|
||||
export * from './IncomingRecommendationEmailRenderer';
|
||||
|
@ -76,6 +76,7 @@ describe('IncomingRecommendationService', function () {
|
||||
describe('sendRecommendationEmail', function () {
|
||||
it('should send email', async function () {
|
||||
await service.sendRecommendationEmail({
|
||||
id: 'test',
|
||||
source: new URL('https://example.com'),
|
||||
sourceTitle: 'Example',
|
||||
sourceSiteTitle: 'Example',
|
||||
@ -90,6 +91,7 @@ describe('IncomingRecommendationService', function () {
|
||||
it('ignores when mention not convertable to incoming recommendation', async function () {
|
||||
readRecommendationByUrl.rejects(new Error('test'));
|
||||
await service.sendRecommendationEmail({
|
||||
id: 'test',
|
||||
source: new URL('https://example.com'),
|
||||
sourceTitle: 'Example',
|
||||
sourceSiteTitle: 'Example',
|
||||
|
@ -3,7 +3,7 @@ module.exports = function (data) {
|
||||
|
||||
// Be careful when you indent the email, because whitespaces are visible in emails!
|
||||
return `
|
||||
You have been recommended by ${recommendation.siteTitle || recommendation.title || recommendation.url}.
|
||||
You have been recommended by ${recommendation.title || recommendation.url}.
|
||||
|
||||
---
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user