mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1516 from omnivore-app/recommend-highlight-api
recommend highlight api
This commit is contained in:
commit
79f739c04c
9 changed files with 263 additions and 20 deletions
|
|
@ -4,31 +4,54 @@ import { createPage, getPageByParam, updatePage } from './pages'
|
|||
export const addRecommendation = async (
|
||||
ctx: PageContext,
|
||||
page: Page,
|
||||
recommendation: Recommendation
|
||||
recommendation: Recommendation,
|
||||
highlightIds?: string[]
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const highlights = page.highlights?.filter((highlight) =>
|
||||
highlightIds?.includes(highlight.id)
|
||||
)
|
||||
|
||||
// check if the page is already recommended to the group
|
||||
const existingPage = await getPageByParam({
|
||||
userId: ctx.uid,
|
||||
url: page.url,
|
||||
})
|
||||
if (existingPage) {
|
||||
if (existingPage.recommendations?.includes(recommendation)) {
|
||||
return existingPage._id
|
||||
const existingHighlights = existingPage.highlights || []
|
||||
|
||||
// remove duplicates
|
||||
const newHighlights =
|
||||
highlights?.filter(
|
||||
(highlight) =>
|
||||
!existingHighlights.find(
|
||||
(existingHighlight) => existingHighlight.quote === highlight.quote
|
||||
)
|
||||
) || []
|
||||
|
||||
const existingRecommendations = existingPage.recommendations || []
|
||||
const isRecommended = existingRecommendations.some(
|
||||
(existingRecommendation) =>
|
||||
existingRecommendation.id === recommendation.id
|
||||
)
|
||||
if (isRecommended && newHighlights.length === 0) {
|
||||
return existingPage.id
|
||||
}
|
||||
|
||||
// update recommendedBy in the existing page
|
||||
const recommendations = (existingPage.recommendations || []).concat(
|
||||
recommendation
|
||||
)
|
||||
// update recommendations in the existing page
|
||||
const recommendations = isRecommended
|
||||
? undefined
|
||||
: existingRecommendations.concat(recommendation)
|
||||
|
||||
await updatePage(
|
||||
existingPage.id,
|
||||
{
|
||||
recommendations,
|
||||
highlights: existingHighlights.concat(newHighlights),
|
||||
},
|
||||
ctx
|
||||
)
|
||||
|
||||
return existingPage.id
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +64,7 @@ export const addRecommendation = async (
|
|||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
sharedAt: new Date(),
|
||||
highlights: [],
|
||||
highlights,
|
||||
readAt: undefined,
|
||||
labels: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export interface RecommendingUser {
|
|||
export interface Recommendation {
|
||||
id: string
|
||||
name: string
|
||||
note: string | null
|
||||
note?: string | null
|
||||
user: RecommendingUser
|
||||
recommendedAt: Date
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,7 @@ export type Mutation = {
|
|||
moveLabel: MoveLabelResult;
|
||||
optInFeature: OptInFeatureResult;
|
||||
recommend: RecommendResult;
|
||||
recommendHighlights: RecommendHighlightsResult;
|
||||
reportItem: ReportItemResult;
|
||||
revokeApiKey: RevokeApiKeyResult;
|
||||
saveArticleReadingProgress: SaveArticleReadingProgressResult;
|
||||
|
|
@ -1332,6 +1333,11 @@ export type MutationRecommendArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationRecommendHighlightsArgs = {
|
||||
input: RecommendHighlightsInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationReportItemArgs = {
|
||||
input: ReportItemInput;
|
||||
};
|
||||
|
|
@ -1785,17 +1791,43 @@ export enum RecommendErrorCode {
|
|||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type RecommendHighlightsError = {
|
||||
__typename?: 'RecommendHighlightsError';
|
||||
errorCodes: Array<RecommendHighlightsErrorCode>;
|
||||
};
|
||||
|
||||
export enum RecommendHighlightsErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type RecommendHighlightsInput = {
|
||||
groupIds: Array<Scalars['ID']>;
|
||||
highlightIds: Array<Scalars['ID']>;
|
||||
note?: InputMaybe<Scalars['String']>;
|
||||
pageId: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type RecommendHighlightsResult = RecommendHighlightsError | RecommendHighlightsSuccess;
|
||||
|
||||
export type RecommendHighlightsSuccess = {
|
||||
__typename?: 'RecommendHighlightsSuccess';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type RecommendInput = {
|
||||
groupIds: Array<Scalars['ID']>;
|
||||
note?: InputMaybe<Scalars['String']>;
|
||||
pageId: Scalars['ID'];
|
||||
recommendedWithHighlights?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type RecommendResult = RecommendError | RecommendSuccess;
|
||||
|
||||
export type RecommendSuccess = {
|
||||
__typename?: 'RecommendSuccess';
|
||||
taskNames: Array<Scalars['String']>;
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type Recommendation = {
|
||||
|
|
@ -3236,6 +3268,11 @@ export type ResolversTypes = {
|
|||
RecentSearchesSuccess: ResolverTypeWrapper<RecentSearchesSuccess>;
|
||||
RecommendError: ResolverTypeWrapper<RecommendError>;
|
||||
RecommendErrorCode: RecommendErrorCode;
|
||||
RecommendHighlightsError: ResolverTypeWrapper<RecommendHighlightsError>;
|
||||
RecommendHighlightsErrorCode: RecommendHighlightsErrorCode;
|
||||
RecommendHighlightsInput: RecommendHighlightsInput;
|
||||
RecommendHighlightsResult: ResolversTypes['RecommendHighlightsError'] | ResolversTypes['RecommendHighlightsSuccess'];
|
||||
RecommendHighlightsSuccess: ResolverTypeWrapper<RecommendHighlightsSuccess>;
|
||||
RecommendInput: RecommendInput;
|
||||
RecommendResult: ResolversTypes['RecommendError'] | ResolversTypes['RecommendSuccess'];
|
||||
RecommendSuccess: ResolverTypeWrapper<RecommendSuccess>;
|
||||
|
|
@ -3638,6 +3675,10 @@ export type ResolversParentTypes = {
|
|||
RecentSearchesResult: ResolversParentTypes['RecentSearchesError'] | ResolversParentTypes['RecentSearchesSuccess'];
|
||||
RecentSearchesSuccess: RecentSearchesSuccess;
|
||||
RecommendError: RecommendError;
|
||||
RecommendHighlightsError: RecommendHighlightsError;
|
||||
RecommendHighlightsInput: RecommendHighlightsInput;
|
||||
RecommendHighlightsResult: ResolversParentTypes['RecommendHighlightsError'] | ResolversParentTypes['RecommendHighlightsSuccess'];
|
||||
RecommendHighlightsSuccess: RecommendHighlightsSuccess;
|
||||
RecommendInput: RecommendInput;
|
||||
RecommendResult: ResolversParentTypes['RecommendError'] | ResolversParentTypes['RecommendSuccess'];
|
||||
RecommendSuccess: RecommendSuccess;
|
||||
|
|
@ -4657,6 +4698,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
|||
moveLabel?: Resolver<ResolversTypes['MoveLabelResult'], ParentType, ContextType, RequireFields<MutationMoveLabelArgs, 'input'>>;
|
||||
optInFeature?: Resolver<ResolversTypes['OptInFeatureResult'], ParentType, ContextType, RequireFields<MutationOptInFeatureArgs, 'input'>>;
|
||||
recommend?: Resolver<ResolversTypes['RecommendResult'], ParentType, ContextType, RequireFields<MutationRecommendArgs, 'input'>>;
|
||||
recommendHighlights?: Resolver<ResolversTypes['RecommendHighlightsResult'], ParentType, ContextType, RequireFields<MutationRecommendHighlightsArgs, 'input'>>;
|
||||
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
|
||||
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
|
||||
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
|
||||
|
|
@ -4837,12 +4879,26 @@ export type RecommendErrorResolvers<ContextType = ResolverContext, ParentType ex
|
|||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RecommendHighlightsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendHighlightsError'] = ResolversParentTypes['RecommendHighlightsError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['RecommendHighlightsErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RecommendHighlightsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendHighlightsResult'] = ResolversParentTypes['RecommendHighlightsResult']> = {
|
||||
__resolveType: TypeResolveFn<'RecommendHighlightsError' | 'RecommendHighlightsSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RecommendHighlightsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendHighlightsSuccess'] = ResolversParentTypes['RecommendHighlightsSuccess']> = {
|
||||
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RecommendResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendResult'] = ResolversParentTypes['RecommendResult']> = {
|
||||
__resolveType: TypeResolveFn<'RecommendError' | 'RecommendSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RecommendSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecommendSuccess'] = ResolversParentTypes['RecommendSuccess']> = {
|
||||
taskNames?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -5737,6 +5793,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
|||
RecentSearchesResult?: RecentSearchesResultResolvers<ContextType>;
|
||||
RecentSearchesSuccess?: RecentSearchesSuccessResolvers<ContextType>;
|
||||
RecommendError?: RecommendErrorResolvers<ContextType>;
|
||||
RecommendHighlightsError?: RecommendHighlightsErrorResolvers<ContextType>;
|
||||
RecommendHighlightsResult?: RecommendHighlightsResultResolvers<ContextType>;
|
||||
RecommendHighlightsSuccess?: RecommendHighlightsSuccessResolvers<ContextType>;
|
||||
RecommendResult?: RecommendResultResolvers<ContextType>;
|
||||
RecommendSuccess?: RecommendSuccessResolvers<ContextType>;
|
||||
Recommendation?: RecommendationResolvers<ContextType>;
|
||||
|
|
|
|||
|
|
@ -1029,6 +1029,7 @@ type Mutation {
|
|||
moveLabel(input: MoveLabelInput!): MoveLabelResult!
|
||||
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
|
||||
recommend(input: RecommendInput!): RecommendResult!
|
||||
recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult!
|
||||
reportItem(input: ReportItemInput!): ReportItemResult!
|
||||
revokeApiKey(id: ID!): RevokeApiKeyResult!
|
||||
saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult!
|
||||
|
|
@ -1248,16 +1249,40 @@ enum RecommendErrorCode {
|
|||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
type RecommendHighlightsError {
|
||||
errorCodes: [RecommendHighlightsErrorCode!]!
|
||||
}
|
||||
|
||||
enum RecommendHighlightsErrorCode {
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
input RecommendHighlightsInput {
|
||||
groupIds: [ID!]!
|
||||
highlightIds: [ID!]!
|
||||
note: String
|
||||
pageId: ID!
|
||||
}
|
||||
|
||||
union RecommendHighlightsResult = RecommendHighlightsError | RecommendHighlightsSuccess
|
||||
|
||||
type RecommendHighlightsSuccess {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
input RecommendInput {
|
||||
groupIds: [ID!]!
|
||||
note: String
|
||||
pageId: ID!
|
||||
recommendedWithHighlights: Boolean
|
||||
}
|
||||
|
||||
union RecommendResult = RecommendError | RecommendSuccess
|
||||
|
||||
type RecommendSuccess {
|
||||
taskNames: [String!]!
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type Recommendation {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
moveFilterResolver,
|
||||
moveLabelResolver,
|
||||
newsletterEmailsResolver,
|
||||
recommendHighlightsResolver,
|
||||
recommendResolver,
|
||||
reminderResolver,
|
||||
reportItemResolver,
|
||||
|
|
@ -193,6 +194,7 @@ export const functionResolvers = {
|
|||
createGroup: createGroupResolver,
|
||||
recommend: recommendResolver,
|
||||
joinGroup: joinGroupResolver,
|
||||
recommendHighlights: recommendHighlightsResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
|
|
@ -646,4 +648,5 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('Groups'),
|
||||
...resultResolveTypeResolver('Recommend'),
|
||||
...resultResolveTypeResolver('JoinGroup'),
|
||||
...resultResolveTypeResolver('RecommendHighlights'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ import {
|
|||
MutationCreateGroupArgs,
|
||||
MutationJoinGroupArgs,
|
||||
MutationRecommendArgs,
|
||||
MutationRecommendHighlightsArgs,
|
||||
RecommendError,
|
||||
RecommendErrorCode,
|
||||
RecommendHighlightsError,
|
||||
RecommendHighlightsErrorCode,
|
||||
RecommendHighlightsSuccess,
|
||||
RecommendSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
|
|
@ -173,9 +177,13 @@ export const recommendResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const recommendedHighlightIds = input.recommendedWithHighlights
|
||||
? page.highlights?.map((h) => h.id)
|
||||
: undefined
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
|
||||
const taskNames = await Promise.all(
|
||||
await Promise.all(
|
||||
groups
|
||||
.map((group) =>
|
||||
group.members
|
||||
|
|
@ -196,7 +204,8 @@ export const recommendResolver = authorized<
|
|||
},
|
||||
recommendedAt: new Date(),
|
||||
},
|
||||
auth
|
||||
auth,
|
||||
recommendedHighlightIds
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -204,10 +213,9 @@ export const recommendResolver = authorized<
|
|||
)
|
||||
|
||||
return {
|
||||
taskNames,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error recommending: ', error)
|
||||
log.error('Error recommending', {
|
||||
error,
|
||||
labels: {
|
||||
|
|
@ -268,3 +276,95 @@ export const joinGroupResolver = authorized<
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const recommendHighlightsResolver = authorized<
|
||||
RecommendHighlightsSuccess,
|
||||
RecommendHighlightsError,
|
||||
MutationRecommendHighlightsArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, signToken }) => {
|
||||
log.info('Recommend highlights', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendHighlightsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['profile'],
|
||||
})
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const groups = await getRepository(Group).find({
|
||||
where: { id: In(input.groupIds) },
|
||||
relations: ['members', 'members.user'],
|
||||
})
|
||||
if (groups.length === 0) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const page = await getPageByParam({ _id: input.pageId, userId: uid })
|
||||
if (!page) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
|
||||
await Promise.all(
|
||||
groups
|
||||
.map((group) =>
|
||||
group.members
|
||||
.filter((member) => member.user.id !== uid)
|
||||
.map((member) =>
|
||||
enqueueRecommendation(
|
||||
member.user.id,
|
||||
page.id,
|
||||
{
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: input.note,
|
||||
user: {
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
username: user.profile.username,
|
||||
profileImageURL: user.profile.pictureUrl,
|
||||
},
|
||||
recommendedAt: new Date(),
|
||||
},
|
||||
auth,
|
||||
input.highlightIds
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error recommending highlights', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendHighlightsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -163,10 +163,11 @@ export function pageRouter() {
|
|||
}
|
||||
const claims = jwt.decode(token) as Claims
|
||||
|
||||
const { userId, pageId, recommendation } = req.body as {
|
||||
const { userId, pageId, recommendation, highlightIds } = req.body as {
|
||||
userId: string
|
||||
pageId: string
|
||||
recommendation: Recommendation
|
||||
highlightIds?: string[]
|
||||
}
|
||||
if (!userId || !pageId || !recommendation) {
|
||||
return res.status(400).send({ errorCode: 'BAD_DATA' })
|
||||
|
|
@ -188,7 +189,8 @@ export function pageRouter() {
|
|||
const recommendedPageId = await addRecommendation(
|
||||
ctx,
|
||||
page,
|
||||
recommendation
|
||||
recommendation,
|
||||
highlightIds
|
||||
)
|
||||
if (!recommendedPageId) {
|
||||
logger.error('Failed to add recommendation to page')
|
||||
|
|
|
|||
|
|
@ -2204,12 +2204,13 @@ const schema = gql`
|
|||
pageId: ID!
|
||||
groupIds: [ID!]!
|
||||
note: String
|
||||
recommendedWithHighlights: Boolean
|
||||
}
|
||||
|
||||
union RecommendResult = RecommendSuccess | RecommendError
|
||||
|
||||
type RecommendSuccess {
|
||||
taskNames: [String!]!
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type RecommendError {
|
||||
|
|
@ -2238,6 +2239,31 @@ const schema = gql`
|
|||
NOT_FOUND
|
||||
}
|
||||
|
||||
input RecommendHighlightsInput {
|
||||
pageId: ID!
|
||||
highlightIds: [ID!]!
|
||||
groupIds: [ID!]!
|
||||
note: String
|
||||
}
|
||||
|
||||
union RecommendHighlightsResult =
|
||||
RecommendHighlightsSuccess
|
||||
| RecommendHighlightsError
|
||||
|
||||
type RecommendHighlightsSuccess {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type RecommendHighlightsError {
|
||||
errorCodes: [RecommendHighlightsErrorCode!]!
|
||||
}
|
||||
|
||||
enum RecommendHighlightsErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
|
|
@ -2318,6 +2344,9 @@ const schema = gql`
|
|||
createGroup(input: CreateGroupInput!): CreateGroupResult!
|
||||
recommend(input: RecommendInput!): RecommendResult!
|
||||
joinGroup(inviteCode: String!): JoinGroupResult!
|
||||
recommendHighlights(
|
||||
input: RecommendHighlightsInput!
|
||||
): RecommendHighlightsResult!
|
||||
}
|
||||
|
||||
# FIXME: remove sort from feedArticles after all cached tabs are closed
|
||||
|
|
|
|||
|
|
@ -413,13 +413,15 @@ export const enqueueRecommendation = async (
|
|||
userId: string,
|
||||
pageId: string,
|
||||
recommendation: Recommendation,
|
||||
authToken: string
|
||||
authToken: string,
|
||||
highlightIds?: string[]
|
||||
): Promise<string> => {
|
||||
const { GOOGLE_CLOUD_PROJECT } = process.env
|
||||
const payload = {
|
||||
userId,
|
||||
pageId,
|
||||
recommendation,
|
||||
highlightIds,
|
||||
}
|
||||
|
||||
const headers = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue