remove knex

This commit is contained in:
Hongbo Wu 2023-09-04 15:31:11 +08:00
parent c1ad9b6f41
commit 65bb4aa85f
40 changed files with 94 additions and 4590 deletions

View file

@ -71,8 +71,6 @@
"intercom-client": "^3.1.4",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.3",
"knex": "2.4.2",
"knex-stringcase": "^1.4.2",
"linkedom": "^0.14.9",
"lodash": "^4.17.21",
"luxon": "^3.2.1",

View file

@ -1,44 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ArticleData, CreateSet, keys as modelKeys, UpdateSet } from './model'
import DataModel from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import { logMethod } from '../helpers'
class ArticleModel extends DataModel<ArticleData, CreateSet, UpdateSet> {
public tableName = Table.PAGES
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async getByUrlAndHash(
params: { url: ArticleData['url']; hash: ArticleData['hash'] },
tx = this.kx
): Promise<ArticleData | null> {
const row: ArticleData | null = await tx(this.tableName)
.select(this.modelKeys)
.where(params)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getByUploadFileId(
uploadFileId: string,
tx = this.kx
): Promise<ArticleData | null> {
const row: ArticleData | null = await tx(this.tableName)
.select(this.modelKeys)
.where({ uploadFileId })
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
}
export default ArticleModel

View file

@ -1,68 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { ContentReader, PageType } from '../../generated/graphql'
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* title | text | | not null |
* description | text | | |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* published_at | timestamp with time zone | | |
* url | text | | not null |
* hash | text | | not null |
* original_html | text | | |
* content | text | | not null |
* author | text | | |
* image | text | | |
* upload_file_id| uuid reference | | |
* ```
* */
export interface ArticleData {
id: string
title: string
description?: string | null
createdAt: Date
publishedAt?: Date | null
url: string
hash: string
originalHtml?: string | null
content: string
pageType: PageType
author?: string | null
image?: string | null
uploadFileId?: string | null
contentReader: ContentReader
}
export const keys = [
'id',
'title',
'description',
'createdAt',
'publishedAt',
'url',
'hash',
'originalHtml',
'content',
'pageType',
'author',
'image',
'uploadFileId',
] as const
export const defaultedKeys = ['id', 'createdAt'] as const
type DefaultedSet = PickTuple<ArticleData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<ArticleData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [] as const
export type UpdateSet = Partialize<PickTuple<ArticleData, typeof updateKeys>>

View file

@ -1,53 +0,0 @@
import {
ArticleSavingRequestData,
CreateSet,
keys as modelKeys,
UpdateSet,
} from './model'
import DataModel from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import { logMethod } from '../helpers'
class ArticleSavingRequestModel extends DataModel<
ArticleSavingRequestData,
CreateSet,
UpdateSet
> {
public tableName = Table.ARTICLE_SAVING_REQUEST
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async getByUserId(
userId: ArticleSavingRequestData['userId'],
tx = this.kx
): Promise<ArticleSavingRequestData | null> {
const row: ArticleSavingRequestData | null = await tx(this.tableName)
.select()
.where({ userId })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getByUserIdAndArticleId(
userId: ArticleSavingRequestData['userId'],
articleId: ArticleSavingRequestData['articleId'],
tx = this.kx
): Promise<ArticleSavingRequestData | null> {
const row: ArticleSavingRequestData | null = await tx(this.tableName)
.select()
.where({ userId, articleId })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
}
export default ArticleSavingRequestModel

View file

@ -1,66 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* article_id | uuid | | |
* status | text | | | 'PROCESSING'::text
* error_code | text | | |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* task_name | text | | |
* ```
* */
export interface ArticleSavingRequestData {
id: string
userId: string
articleId?: string | null
status?: string | null
errorCode?: string | null
createdAt: Date
updatedAt: Date
taskName?: string
elasticPageId?: string
}
export const keys = [
'id',
'userId',
'articleId',
'status',
'errorCode',
'createdAt',
'updatedAt',
'taskName',
'elasticPageId',
] as const
export const defaultedKeys = ['id', 'createdAt', 'updatedAt', 'status'] as const
type DefaultedSet = PickTuple<ArticleSavingRequestData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<ArticleSavingRequestData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [
'articleId',
'status',
'errorCode',
'taskName',
'elasticPageId',
] as const
export type UpdateSet = PickTuple<ArticleSavingRequestData, typeof updateKeys>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<
ArticleSavingRequestData,
typeof getByParametersKeys
>

View file

@ -1,156 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import DataModel from './model'
import { Knex } from 'knex'
import DataLoader from 'dataloader'
import { snakeCase } from 'snake-case'
import { buildLogger } from '../utils/logger'
import { SetClaimsRole } from '../utils/dictionary'
export const logger = buildLogger('datalayer')
export const setClaims = async (
tx: Knex.Transaction,
uuid?: string,
userRole?: string
): Promise<void> => {
const uid = uuid || '00000000-0000-0000-0000-000000000000'
const dbRole =
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
return tx.raw('SELECT * from omnivore.set_claims(?, ?)', [uid, dbRole])
}
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
/**
* Set to true to enable DB request statistics collection
* When on, every 5 seconds CallCounter will be printing an object that counts DB calls by types.
* **Must be off on prod**.
* */
export const ENABLE_DB_REQUEST_LOGGING = false
/** Doesnt preserve function context */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<A extends any[]>(
func: (...args: A) => void,
wait: number,
immediate: boolean
): (...args: A) => void {
let timeout: NodeJS.Timeout | null
return function (...args: A): void {
if (timeout) {
clearTimeout(timeout)
}
timeout = global.setTimeout(function () {
timeout = null
if (!immediate) func(...args)
}, wait)
if (immediate && !timeout) func(...args)
}
}
class CallCounter {
private counter: Record<string, number> = {}
log(tableName: string, methodName: string, params: string): void {
const key = `${tableName}.${methodName}`
if (key in this.counter) {
this.counter[key]++
} else {
this.counter[key] = 1
}
const count = this.counter[key]
// display in console the function call details
logger.info(`Call (${count}): ${key}(${params})`, {
labels: {
source: 'callCounter',
},
})
this.printCounts()
}
printCounts = debounce(() => logger.info(this.counter), 5000, false)
}
export const globalCounter = new CallCounter()
export function logMethod(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
target: Record<string, any>,
propertyName: string,
propertyDesciptor: PropertyDescriptor
): PropertyDescriptor {
const method = propertyDesciptor.value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
propertyDesciptor.value = async function (
this: DataModel<any, any, any>,
...args: any[]
) {
// invoke wrapped function and get its return value
const result = await method.apply(this, args)
if (ENABLE_DB_REQUEST_LOGGING) {
const params = args.map((a) => JSON.stringify(a)).join()
globalCounter.log(this.tableName, propertyName, params)
}
return result
}
return propertyDesciptor
}
/**
* Creates a non-caching loader that fetches model data by a foreign key
* @example Fetching replies by question ID
* */
export const edgeLoader = <
ModelData extends { id: string },
ForeignKey extends keyof ModelData
>(
kx: Knex,
tableName: string,
foreignKey: ForeignKey,
modelKeys: readonly string[]
): DataLoader<string, ModelData[]> =>
new DataLoader(
async (keys: readonly string[]) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(
tableName,
`load_by_${foreignKey}`,
JSON.stringify(keys)
)
}
const columnName = snakeCase(foreignKey as string)
try {
const rows: ModelData[] = await kx(tableName)
.select(modelKeys)
.whereIn(columnName, keys)
const keyMap: Record<string, ModelData[]> = {}
for (const row of rows) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const keyValue = row[foreignKey] as any as string
if (keyValue in keyMap) {
keyMap[keyValue].push(row)
} else keyMap[keyValue] = [row]
}
const result = []
for (const key of keys) {
result.push(keyMap[key] || [])
}
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error: ', e)
throw e
}
},
{ cache: false }
)

View file

@ -1,179 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
import { CreateSet, HighlightData, keys as modelKeys, UpdateSet } from './model'
import { Table } from '../../utils/dictionary'
import { Knex } from 'knex'
import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logger } from '../helpers'
import DataLoader from 'dataloader'
class HighlightModel extends DataModel<HighlightData, CreateSet, UpdateSet> {
protected batchLoader: DataLoader<string, HighlightData[]>
public tableName = Table.HIGHLIGHT
protected modelKeys = modelKeys
batchGet: DataLoader<string, HighlightData[]>['load']
constructor(kx: Knex, cache = true) {
super(kx, cache)
// override dataloader to skip rows where 'deleted = true'
this.loader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
}
try {
const rows: HighlightData[] = await kx
.table(this.tableName)
.select(this.modelKeys)
.whereIn('id', keys)
.andWhere('deleted', false)
.limit(MAX_RECORDS_LIMIT)
const keyMap: Record<string, HighlightData> = {}
for (const row of rows) {
if (row.id in keyMap) continue
keyMap[row.id] = row
}
const result = keys.map((key) => keyMap[key])
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error ', e)
throw e
}
},
{ cache }
)
// separate dataloader for fetching grouped highlights
this.batchLoader = new DataLoader(async (articleIds) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(
this.tableName,
'batchLoad',
JSON.stringify(articleIds)
)
}
const result = await this.kx
.table(Table.HIGHLIGHT)
.select(modelKeys)
.whereIn('elasticPageId', articleIds)
.andWhere('deleted', false)
.orderBy(`${Table.HIGHLIGHT}.created_at`, 'desc')
.limit(MAX_RECORDS_LIMIT)
.then((highlights: HighlightData[]) => {
// group highlights so that each article has its own array of highlights
const result: HighlightData[][] = Array.from(
Array(articleIds.length),
() => []
)
// keep track of nested array indices to preserve the order
const positions = articleIds.reduce(
(res, cur, i) => ({ ...res, [cur]: i }),
{} as { [key: string]: number }
)
highlights.forEach((highlight) => {
const index = positions[highlight.elasticPageId]
result[index].push({
...highlight,
updatedAt: highlight.updatedAt || highlight.createdAt,
})
this.loader.prime(highlight.id, highlight)
})
return result
})
if (!result.length) {
return new Array(articleIds.length).fill([])
}
return result
})
this.get = this.loader.load.bind(this.loader)
this.getMany = this.loader.loadMany.bind(this.loader)
this.batchGet = this.batchLoader.load.bind(this.batchLoader)
}
async unshareAllHighlights(
articleId: string,
userId: string,
tx: Knex.Transaction
): Promise<HighlightData[]> {
const rows: HighlightData[] = await tx(this.tableName)
.update({ sharedAt: null })
.where({ elasticPageId: articleId, userId })
.andWhere(tx.raw(`shared_at is not null`))
.returning(this.modelKeys)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
async delete(
id: string,
tx: Knex.Transaction
): Promise<HighlightData | { error: DataModelError }> {
const [row]: HighlightData[] = await tx(this.tableName)
.update({ deleted: true })
.where({ id })
.returning(this.modelKeys)
if (!row) return { error: DataModelError.notFound }
this.loader.clear(id)
return row
}
async deleteMany(
idList: string[],
tx: Knex.Transaction
): Promise<HighlightData[] | { error: DataModelError }> {
const rows: HighlightData[] = await tx(this.tableName)
.update({ deleted: true })
.whereIn('id', idList)
.returning(this.modelKeys)
if (!rows.length) return { error: DataModelError.notFound }
idList.forEach((id) => this.loader.clear(id))
return rows
}
async getForUserArticle(
userId: string,
articleId: string
): Promise<HighlightData[]> {
const highlights: HighlightData[] = await this.kx
.table(Table.HIGHLIGHT)
.select(modelKeys)
.where('user_id', userId)
.andWhere('elastic_page_id', articleId)
.andWhere('deleted', false)
.orderBy(`${Table.HIGHLIGHT}.created_at`, 'desc')
.limit(MAX_RECORDS_LIMIT)
const result = highlights.map((highlight) => {
if (!highlight.updatedAt) {
highlight.updatedAt = highlight.createdAt
}
this.loader.prime(highlight.id, highlight)
return highlight
})
return result
}
}
export default HighlightModel

View file

@ -1,73 +0,0 @@
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* short_id | varchar(14) | | not null |
* user_id | uuid | | not null |
* article_id | uuid | | not null |
* quote | text | | not null |
* prefix | varchar(5000) | | |
* suffix | varchar(5000) | | |
* patch | text | | not null |
* annotation | text | | |
* deleted | boolean | | not null | false
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | |
* shared_at | timestamp with time zone | | |
* ```
* */
export interface HighlightData {
id: string
shortId: string
userId: string
articleId?: string
quote: string
prefix?: string | null
suffix?: string | null
patch: string
annotation?: string | null
deleted: boolean
createdAt: Date
updatedAt?: Date | null
sharedAt?: Date | null
elasticPageId: string
}
export const keys = [
'id',
'shortId',
'userId',
'articleId',
'quote',
'prefix',
'suffix',
'patch',
'annotation',
'deleted',
'createdAt',
'updatedAt',
'sharedAt',
'elasticPageId',
] as const
export const defaultedKeys = [
'createdAt',
'updatedAt',
'sharedAt',
'deleted',
] as const
type DefaultedSet = PickTuple<HighlightData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<HighlightData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = ['annotation', 'sharedAt'] as const
export type UpdateSet = Partialize<PickTuple<HighlightData, typeof updateKeys>>

View file

@ -1,20 +0,0 @@
import { env } from '../env'
import knex from 'knex'
import knexStringcase from 'knex-stringcase'
export const kx = knex(
knexStringcase({
client: 'pg',
connection: {
host: env.pg.host,
port: env.pg.port,
user: env.pg.userName,
password: env.pg.password,
database: env.pg.dbName,
},
pool: {
max: env.pg.pool.max,
acquireTimeoutMillis: 40000,
},
})
)

View file

@ -1,685 +0,0 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import {
CreateSet,
keys as modelKeys,
ParametersSet,
UpdateSet,
UserArticleData,
UserFeedArticleData,
} from './model'
import DataModel, { MAX_RECORDS_LIMIT } from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import {
Article,
PageType,
SortOrder,
SortParams,
} from '../../generated/graphql'
import {
ENABLE_DB_REQUEST_LOGGING,
globalCounter,
logMethod,
logger,
} from '../helpers'
import DataLoader from 'dataloader'
import { ArticleData } from '../article/model'
import { InFilter, LabelFilter, ReadFilter } from '../../utils/search'
type PartialArticle = Omit<
Article,
'updatedAt' | 'readingProgressPercent' | 'readingProgressAnchorIndex'
>
type UserArticleStats = {
highlightsCount: string
annotationsCount: string
}
const LINK_COLS = [
'omnivore.links.id as linkId',
'omnivore.links.userId',
'omnivore.links.slug',
'omnivore.links.article_url as url',
'omnivore.links.createdAt',
'omnivore.links.sharedAt',
'omnivore.links.savedAt',
'omnivore.links.sharedComment',
'omnivore.links.articleReadingProgress',
'omnivore.links.articleReadingProgressAnchorIndex',
'omnivore.pages.id',
'omnivore.pages.pageType',
'omnivore.pages.url as originalArticleUrl',
'omnivore.pages.title',
'omnivore.pages.description',
'omnivore.pages.hash',
'omnivore.pages.author',
'omnivore.pages.image',
'omnivore.pages.pageType',
'omnivore.pages.publishedAt',
]
// When fetching the library list we don't need to
// pull all the content out of the database into
// memory just to discard it later
const linkColsWithoutContent = (tx: Knex) => {
return [
tx.raw(`
CASE
WHEN omnivore.links.article_reading_progress < 98 THEN 'UNREAD'
ELSE 'READ'
END
as read_status
`),
tx.raw(`
CASE
WHEN omnivore.links.archived_at is null THEN false
ELSE true
END
as is_archived
`),
...LINK_COLS,
]
}
const linkCols = (tx: Knex) => {
return [
'omnivore.pages.content',
'omnivore.pages.originalHtml',
...linkColsWithoutContent(tx),
]
}
const readFilterQuery = (filter: ReadFilter) => {
switch (filter) {
case ReadFilter.ALL:
return 'true'
case ReadFilter.UNREAD:
return 'omnivore.links.article_reading_progress < 98'
case ReadFilter.READ:
return 'omnivore.links.article_reading_progress >= 98'
}
}
class UserArticleModel extends DataModel<
UserArticleData,
CreateSet,
UpdateSet
> {
public tableName = Table.LINKS
protected modelKeys = modelKeys
protected userAndArticleLoader: DataLoader<
{ userId: string; articleId: string },
UserArticleData
>
protected userArticleStatsLoader: DataLoader<string, UserArticleStats>
getStats: DataLoader<string, UserArticleStats>['load']
constructor(kx: Knex, cache = true) {
super(kx, cache)
this.userAndArticleLoader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(
this.tableName,
'userId_articleId_load',
JSON.stringify(keys)
)
}
try {
const rows: UserArticleData[] = await this.kx(this.tableName)
.select(this.modelKeys)
.whereIn(
['articleId', 'userId'],
keys.map((key) => [key.articleId, key.userId])
)
.limit(MAX_RECORDS_LIMIT)
const keyMap: Map<string, UserArticleData> = new Map()
for (const row of rows) {
const hash = `${row.userId}.${row.articleId}`
if (keyMap.has(hash)) continue
keyMap.set(hash, row)
}
const result = keys.map(({ userId, articleId }) =>
keyMap.get(`${userId}.${articleId}`)
) as UserArticleData[]
// logger.debug('\n\n\n\n\nResult for userId_articleId_load', { keys, result });
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error: ', e)
throw e
}
},
{ cache }
)
this.userArticleStatsLoader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(
this.tableName,
'userArticleId_stats_load',
JSON.stringify(keys)
)
}
try {
const rows: ({ id: string } & UserArticleStats)[] = await this.kx(
`${this.tableName} as ua`
)
.select([
'ua.id',
this.kx.raw('count(h2.id) as highlights_count'),
this.kx.raw('count(h2.annotation) as annotations_count'),
])
.leftJoin(`${Table.HIGHLIGHT} as h2`, function () {
this.on('h2.article_id', '=', 'ua.article_id')
this.andOn('h2.user_id', '=', 'ua.user_id')
this.andOn('h2.deleted', '=', kx.raw('FALSE'))
})
.whereIn('ua.id', keys)
.groupBy(['ua.id'])
.limit(MAX_RECORDS_LIMIT)
const keyMap: Map<string, UserArticleStats> = new Map()
for (const row of rows) {
if (keyMap.has(row.id)) continue
keyMap.set(row.id, row)
}
const result = keys.map((id) => {
const stats = keyMap.get(id)
if (!stats)
throw new Error('User article stats data loader state missmatch!')
return stats
})
// logger.debug('\n\n\n\n\nResult for userArticleId_stats_load', { keys, result });
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error: ', e)
throw e
}
},
{ cache }
)
this.getStats = this.userArticleStatsLoader.load.bind(
this.userArticleStatsLoader
)
}
@logMethod
async getByParameters<K extends keyof ParametersSet>(
userId: UserArticleData['userId'],
params: Record<K, UserArticleData[K]>,
tx = this.kx
): Promise<UserArticleData | null> {
const row: UserArticleData | null = await tx(this.tableName)
.select(this.modelKeys)
.where({ userId })
.andWhere(params)
.orderBy('created_at', 'desc')
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async articlesForUser<K extends keyof ParametersSet>(
userId: UserArticleData['userId'],
tx = this.kx
): Promise<UserArticleData[] | null> {
const rows: UserArticleData[] | null = await tx(this.tableName)
.select(this.modelKeys)
.where({ userId })
.orderBy('saved_at', 'desc')
.limit(MAX_RECORDS_LIMIT)
if (!rows || !rows.length) {
return null
}
rows.forEach((r) => this.loader.prime(r.id, r))
return rows
}
// @logMethod
async getByArticleId(
userId: UserArticleData['userId'],
articleId: UserArticleData['articleId'],
_tx = this.kx
): Promise<UserArticleData | null> {
return this.userAndArticleLoader.load({ userId, articleId })
}
/* TODO: move to separate dataloader for checking list of articles have been saved or not */
@logMethod
async getCountByParameters<K extends keyof ParametersSet>(
userId: UserArticleData['userId'],
params: Record<K, UserArticleData[K]>,
tx = this.kx
): Promise<number> {
const [{ rowCount }] = await tx(this.tableName)
.count('id as rowCount')
.where({ userId })
.andWhere(params)
return rowCount as number
}
@logMethod
async updateByArticleId<K extends keyof UpdateSet>(
userId: UserArticleData['userId'],
articleId: UserArticleData['articleId'],
params: Record<K, UserArticleData[K]>,
tx = this.kx
): Promise<UserArticleData | null> {
const rows: UserArticleData[] | null = await tx(this.tableName)
.update(params)
.where({ articleId, userId })
.returning(this.modelKeys)
if (!rows) return null
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows[0] || null
}
@logMethod
async updateByIds<K extends keyof UpdateSet>(
ids: UserArticleData['id'][],
params: Record<K, UserArticleData[K]>,
tx = this.kx
): Promise<UserArticleData | null> {
const rows: UserArticleData[] | null = await tx(this.tableName)
.update(params)
.whereIn('id', ids)
.returning(this.modelKeys)
if (!rows) return null
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows[0] || null
}
/**
* @deprecated
*/
async getUserFeedArticlesLegacy(
userId: string,
tx = this.kx
): Promise<UserFeedArticleData[] | null> {
const rows = await tx(this.tableName)
.select([
'omnivore.links.id',
'omnivore.links.user_id',
'omnivore.links.article_id',
'omnivore.links.shared_at',
'omnivore.links.saved_at',
'omnivore.links.shared_comment',
])
.leftJoin('omnivore.user_friends', function () {
this.on(
tx.raw('omnivore.user_friends.user_id::text = ?', [userId])
).andOn(
'omnivore.user_friends.friend_user_id',
'=',
'omnivore.links.user_id'
)
})
.whereNotNull('omnivore.links.shared_at')
.andWhere(function () {
this.whereRaw('omnivore.links.user_id::text = ?', [
userId,
]).orWhereNotNull('omnivore.user_friends.id')
})
.orderBy('omnivore.links.shared_at', 'DESC')
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async getSharedArticlesCount(
userId: string,
tx = this.kx
): Promise<UserFeedArticleData[] | null> {
const rows = await tx(this.tableName)
.select([tx.raw('count(omnivore.links.id) as shared_articles_count')])
.whereNotNull('omnivore.links.shared_at')
.andWhere('omnivore.links.user_id', userId)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows[0]?.sharedArticlesCount || 0
}
async getUserSharedArticles(
userId: string,
tx = this.kx
): Promise<UserFeedArticleData[] | null> {
const rows = await tx(this.tableName)
.select([
'omnivore.links.id',
'omnivore.links.user_id',
'omnivore.links.article_id',
'omnivore.links.shared_at',
'omnivore.links.saved_at',
'omnivore.links.shared_comment',
])
.whereNotNull('omnivore.links.shared_at')
.andWhere('omnivore.links.user_id', userId)
.orderBy('omnivore.links.shared_at', 'DESC')
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async getPaginated(
args: {
cursor: string
first: number
sort?: SortParams
query?: string
inFilter: InFilter
readFilter: ReadFilter
typeFilter: PageType | undefined
labelFilters: LabelFilter[]
},
userId: string,
tx = this.kx,
notNullField: string | null = null
): Promise<[PartialArticle[], number] | null> {
const { cursor, first, sort, query, readFilter } = args
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
const whereOperator = sort?.order === SortOrder.Ascending ? '>=' : '<='
const queryPromise = tx(this.tableName)
.select(linkColsWithoutContent(tx))
.innerJoin(Table.PAGES, 'pages.id', 'links.article_id')
.where({ 'links.user_id': userId })
.where(tx.raw(readFilterQuery(readFilter)))
if (query) {
const searchQuery = tx.raw(`tsv @@ websearch_to_tsquery(?)`, query)
queryPromise.where(searchQuery)
}
if (args.typeFilter) {
queryPromise.where(
tx.raw(`omnivore.pages.page_type = ?`, args.typeFilter)
)
}
if (args.inFilter !== InFilter.ALL) {
switch (args.inFilter) {
case InFilter.INBOX:
queryPromise.whereNull('links.archivedAt')
break
case InFilter.ARCHIVE:
queryPromise.whereNotNull('links.archivedAt')
break
}
}
if (notNullField) {
queryPromise.whereNotNull(notNullField)
}
const [{ count: totalCount }] =
(await tx(queryPromise.clone().as('subq')).count()) || '0'
// This is a temp hack as we move from time based cursors to
// using offset, this will be replaced when we change the
// storage backend.
if (cursor && Number(cursor) > 1546300800000) {
queryPromise.where(
`omnivore.links.saved_at`,
whereOperator,
new Date(parseInt(cursor) + (sortOrder === 'ASC' ? -1 : 1)) //Time Comparison Bias
)
} else if (cursor && Number(cursor) <= 1546300800000) {
queryPromise.offset(Number(cursor))
}
// If first is greater than 100 set it to 100
const limit = first > 100 ? 100 : first
queryPromise
.orderBy('omnivore.links.saved_at', sortOrder)
.orderBy('omnivore.links.created_at', sortOrder)
.orderBy('omnivore.links.id', sortOrder)
.limit(limit)
const rows = await queryPromise
for (const row of rows) {
this.loader.prime(row.id, row)
}
return [rows, parseInt(totalCount as string)]
}
@logMethod
async getUserFeedArticlesPaginatedWithHighlights(
args: { cursor: string; first: number; sharedByUser?: string | null },
userId: string,
tx = this.kx
): Promise<
| (UserFeedArticleData & {
highlightsCount: number
annotationsCount: number
})[]
| null
> {
const { cursor, first, sharedByUser } = args
// let userArticlesListQuery;
// Getting the list of friends user ids
const friendsListQuery = sharedByUser
? ([] as never)
: tx(Table.USER_FRIEND).select('friendUserId').where('userId', userId)
// Getting the links ids list that applies to the "My feed" page
const userArticlesListQuery = tx(this.tableName)
.select('id')
.where(function () {
this.whereIn('userId', friendsListQuery)
this.orWhere('userId', sharedByUser || userId)
})
.whereNotNull('sharedAt')
// Collecting the highlights and annotations stats for the links records
const userArticlesStatsQuery = tx(`${this.tableName} as ua`)
.select([
'ua.article_id',
'ua.user_id',
tx.raw('count(h2.id) as highlights_count'),
tx.raw(
`count(case when h2.annotation is not null and h2.annotation <> '' then 1 else null end) as annotations_count`
),
])
.leftJoin(`${Table.HIGHLIGHT} as h2`, function () {
this.on('h2.article_id', '=', 'ua.article_id')
this.andOn('h2.user_id', '=', 'ua.user_id')
this.andOn('h2.deleted', '=', tx.raw('FALSE'))
})
// TODO: Check if using the join isntead could be more efficient approach here
// (https://github.com/omnivore-app/omnivore/pull/1053#discussion_r604914773)
.whereIn('ua.id', userArticlesListQuery)
.where('ua.shared_with_highlights', 'TRUE')
.groupBy(['ua.article_id', 'ua.user_id'])
// Combining required link columns with the stats calculated
const userArticlesQuery = tx(`${this.tableName} as ua`)
.select([
'ua.id',
'ua.article_id',
tx.raw('null as highlight_id'),
'ua.user_id',
'uas.highlights_count',
'uas.annotations_count',
'ua.shared_at',
'ua.saved_at',
'ua.shared_comment',
'ua.shared_with_highlights',
])
.leftJoin(userArticlesStatsQuery.as('uas'), function () {
this.on('uas.article_id', '=', 'ua.article_id')
this.andOn('uas.user_id', '=', 'ua.user_id')
})
.whereIn('ua.id', userArticlesListQuery)
// Getting the shared highlights
const highlightsQuery = tx(`${Table.HIGHLIGHT} as hi`)
.select([
'id',
'article_id',
'id as highlight_id',
'user_id',
tx.raw('0 as highlights_count'),
tx.raw('0 as annotations_count'),
'shared_at',
tx.raw('null as saved_at'),
tx.raw('null as shared_comment'),
tx.raw('null as shared_with_highlights'),
])
.where(function () {
this.whereIn('userId', friendsListQuery)
this.orWhere('userId', sharedByUser || userId)
})
.where(tx.raw('deleted is not true'))
.andWhere(tx.raw('shared_at is not null'))
if (sharedByUser) {
highlightsQuery.andWhere('user_id', sharedByUser)
}
// Merging links record with the shared highlights
// NOTE: Number of columns and order should match in both queries
const feedItemsQuery = tx
.union([userArticlesQuery, highlightsQuery])
.orderBy('shared_at', 'DESC')
// Appending resulting query with a cursor if provided
const resultQuery = cursor
? tx
.select('*')
.from(feedItemsQuery.as('r'))
.where(
`shared_at`,
'<=',
new Date(parseInt(cursor) + 1) //Time Comparison Bias
)
: feedItemsQuery
resultQuery.limit(first)
const rows = await resultQuery
for (const row of rows) {
this.loader.prime(row.id, row)
this.userArticleStatsLoader.prime(row.id, {
highlightsCount: row.highlightsCount,
annotationsCount: row.annotationsCount,
})
}
return rows
}
@logMethod
async getForUser(
userId: string,
articleId: string,
tx = this.kx
): Promise<(ArticleData & UserArticleData) | null> {
const row = await tx(Table.LINKS)
.select(linkCols(tx))
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
.where('links.user_id', userId)
.where('links.article_id', articleId)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getBySlug(
username: string,
slug: string,
tx = this.kx
): Promise<(ArticleData & UserArticleData) | null> {
const row = await tx(Table.LINKS)
.select(linkCols(tx))
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
.innerJoin(Table.USER_PROFILE, 'links.user_id', 'user_profile.user_id')
.where('user_profile.username', username)
.where('links.slug', slug)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
async getByUserIdAndSlug(
uid: string,
slug: string,
tx = this.kx
): Promise<(ArticleData & UserArticleData) | null> {
const row = await tx(Table.LINKS)
.select(linkCols(tx))
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
.where('links.user_id', uid)
.where('links.slug', slug)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async setBookmarkOnMultiple(
userId: string,
articles: CreateSet[],
tx = this.kx
): Promise<UserArticleData[] | null> {
const rows = await tx(Table.LINKS)
.insert(articles)
.returning(this.modelKeys)
if (!rows) return null
rows.forEach((r) => this.loader.prime(r.id, r))
return rows
}
}
export default UserArticleModel

View file

@ -1,112 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------------------------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* article_id | uuid | | not null |
* article_url | text | | not null |
* slug | text | | not null |
* article_hash | text | | not null |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* shared_at | timestamp with time zone | | |
* shared_comment | text | | |
* article_reading_progress | real | | not null | 0
* article_reading_progress_anchor_index | integer | | not null | 0
* saved_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* shared_with_highlights | boolean | | | false
* ```
* */
export interface UserArticleData {
id: string
userId: string
articleId: string
slug: string
articleUrl: string
articleHash: string
createdAt: Date
updatedAt: Date
savedAt: Date
sharedAt?: Date | null
archivedAt?: Date | null
sharedComment?: string | null
articleReadingProgress: number
articleReadingProgressAnchorIndex: number
sharedWithHighlights?: boolean
isArchived: boolean
}
export interface UserFeedArticleData {
id: string
userId: string
articleId: string
sharedAt: Date
createdAt: Date
sharedComment?: string | null
sharedWithHighlights?: boolean
}
export const keys = [
'id',
'userId',
'articleId',
'slug',
'articleUrl',
'articleHash',
'createdAt',
'updatedAt',
'savedAt',
'sharedAt',
'archivedAt',
'sharedComment',
'articleReadingProgress',
'articleReadingProgressAnchorIndex',
'sharedWithHighlights',
] as const
export const defaultedKeys = [
'id',
'createdAt',
'updatedAt',
'savedAt',
'articleReadingProgress',
'articleReadingProgressAnchorIndex',
'articleDeleted',
'sharedWithHighlights',
] as const
type DefaultedSet = PickTuple<UserArticleData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UserArticleData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [
'slug',
'savedAt',
'articleId',
'articleUrl',
'articleHash',
'sharedAt',
'archivedAt',
'sharedComment',
'articleReadingProgress',
'articleReadingProgressAnchorIndex',
'sharedWithHighlights',
] as const
export type UpdateSet = Partialize<
PickTuple<UserArticleData, typeof updateKeys>
>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<
UserArticleData,
typeof getByParametersKeys
>

View file

@ -1,49 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Knex } from 'knex'
import { LinkShareInfo } from '../../generated/graphql'
import { getPageByParam } from '../../elastic/pages'
// once we have links setup properly in the API we will remove this method
// and have a getShareInfoForLink method
export const getShareInfoForArticle = async (
kx: Knex,
userId: string,
articleId: string
): Promise<LinkShareInfo | undefined> => {
// TEMP: because the old API uses articles instead of Links, we are actually
// getting an article ID here and need to map it to a link ID. When the API
// is updated to use Links instead of Articles this will be removed.
const page = await getPageByParam({ userId, _id: articleId })
if (!page) {
return undefined
}
const result = await kx('omnivore.link_share_info')
.select('*')
.where({ elastic_page_id: page.id })
.first()
return result
}
export const createOrUpdateLinkShareInfo = async (
tx: Knex,
linkId: string,
title: string,
description: string
): Promise<LinkShareInfo> => {
const item = { linkId, title, description }
const [result]: LinkShareInfo[] = await tx('omnivore.link_share_info')
.insert(item)
.onConflict('link_id')
.merge()
.returning('*')
if (!result) {
return Promise.reject(new Error('No result'))
}
return result
}

View file

@ -1,183 +0,0 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import DataLoader from 'dataloader'
import { Knex } from 'knex'
import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logger } from './helpers'
export enum DataModelError {
notFound = 'NOT_FOUND',
}
export const MAX_RECORDS_LIMIT = 1000
abstract class DataModel<
ModelData extends { id: string },
CreateSet,
UpdateSet
> {
protected loader: DataLoader<string, ModelData>
public tableName!: string
protected modelKeys!: readonly (keyof ModelData)[]
kx: Knex
get: DataLoader<string, ModelData>['load']
getMany: DataLoader<string, ModelData>['loadMany']
/**
* @param kx - DB connection
* @param userId - user id to use when executing data loader queries
* */
constructor(kx: Knex, cache = true) {
this.kx = kx
this.loader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows: ModelData[] = await this.kx(this.tableName)
.select(this.modelKeys)
.whereIn('id', keys)
const keyMap: Record<string, ModelData> = {}
for (const row of rows) {
if (row.id in keyMap) continue
keyMap[row.id] = row
}
const result = keys.map((key) => keyMap[key])
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error: ', e)
throw e
}
},
{ cache }
)
this.get = this.loader.load.bind(this.loader)
this.getMany = this.loader.loadMany.bind(this.loader)
}
/**
* Gets entity that accomplish "whereIn" condition
* @param field - string specifying a field to use in "whereIn" condition
* @param values - an array of values to compare in "whereIn" condition
* @param tx - DB transaction
* @example
* // Return entity that match id comparison
* const collaboratorsIds = ['1', '2'];
* return models.user.getWhereIn('id', collaboratorsIds);
*/
async getWhereIn<K extends keyof ModelData>(
field: K,
values: ModelData[K][],
tx = this.kx,
notNullField: string | null = null
): Promise<ModelData[]> {
let queryPromise = tx(this.tableName)
.select(this.modelKeys)
.whereIn(field, values)
.orderBy('created_at', 'desc')
if (notNullField) {
queryPromise = queryPromise.whereNotNull(notNullField)
}
const rows: ModelData[] = await queryPromise
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
async getAll(tx = this.kx): Promise<ModelData[]> {
const rows: ModelData[] = await tx(this.tableName)
.select(this.modelKeys)
.orderBy('created_at', 'desc')
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
async create(data: CreateSet, tx = this.kx): Promise<ModelData> {
const [row]: ModelData[] = await tx(this.tableName)
.insert(data)
.returning('*')
this.loader.prime(row.id, row)
return row
}
async createMany(
data: CreateSet[],
tx: Knex.Transaction
): Promise<ModelData[]> {
const rows: ModelData[] = await tx
.batchInsert(this.tableName, data as never)
.returning('*')
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
async update(id: string, data: UpdateSet, tx = this.kx): Promise<ModelData> {
const [row]: ModelData[] = await tx(this.tableName)
.update(data)
.where({ id })
.returning('*')
this.loader.prime(id, row)
return row
}
async delete(
id: string,
tx: Knex.Transaction
): Promise<ModelData | { error: DataModelError }> {
const [row]: ModelData[] = await tx(this.tableName)
.where({ id })
.delete()
.returning('*')
if (!row) return { error: DataModelError.notFound }
this.loader.clear(id)
return row
}
async deleteWhere<K extends keyof ModelData>(
params: Record<K, ModelData[K]>,
tx: Knex.Transaction
): Promise<ModelData[]> {
const rows: ModelData[] = await tx(this.tableName)
.andWhere(params)
.delete()
.returning('*')
for (const row of rows) {
this.loader.clear(row.id)
}
return rows
}
async deleteWhereIn<K extends keyof ModelData>(
params: Record<K, ModelData[K][]>,
tx: Knex.Transaction
): Promise<ModelData[]> {
const rows: ModelData[] = await tx(this.tableName)
.where((builder) => {
for (const field in params) {
builder.whereIn(field, params[field])
}
})
.delete()
.returning('*')
for (const row of rows) {
this.loader.clear(row.id)
}
return rows
}
}
export default DataModel

View file

@ -1,117 +0,0 @@
import {
ENABLE_DB_REQUEST_LOGGING,
globalCounter,
logMethod,
} from './../helpers'
import { CreateSet, keys as modelKeys, ReactionData, UpdateSet } from './model'
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import DataLoader from 'dataloader'
class ReactionModel extends DataModel<ReactionData, CreateSet, UpdateSet> {
public tableName = Table.REACTION
protected modelKeys = modelKeys
protected getBatchLoader: (
type: 'highlightId' | 'userArticleId'
) => DataLoader<string, ReactionData[]>
batchGetFromArticle: DataLoader<string, ReactionData[]>['load']
batchGetFromHighlight: DataLoader<string, ReactionData[]>['load']
constructor(kx: Knex, cache = true) {
super(kx, cache)
// override dataloader to skip rows where 'deleted = true'
// separate dataloader for fetching grouped highlights
this.getBatchLoader = (type: 'highlightId' | 'userArticleId') =>
new DataLoader(async (ids) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(this.tableName, 'batchLoad', JSON.stringify(ids))
}
const result: ReactionData[][] = await this.kx(Table.REACTION)
.select(modelKeys)
.whereIn(type, ids)
.andWhere('deleted', false)
.orderBy(`${Table.REACTION}.created_at`, 'desc')
.limit(MAX_RECORDS_LIMIT)
.then((reactions: ReactionData[]) => {
// group highlights so that each article has its own array of highlights
const arr: ReactionData[][] = Array.from(
Array(ids.length),
() => []
)
// keep track of nested array indices to preserve the order
const positions = ids.reduce(
(acc, cur, i) => ({ ...acc, [cur]: i }),
{} as { [key: string]: number }
)
reactions.forEach((re) => {
const pos = re[type]
if (!pos) {
return
}
const index = positions[pos]
arr[index].push({
...re,
updatedAt: re.updatedAt || re.createdAt,
})
this.loader.prime(re.id, re)
})
return arr
})
return result
})
this.get = this.loader.load.bind(this.loader)
this.getMany = this.loader.loadMany.bind(this.loader)
const ba = this.getBatchLoader('userArticleId')
const bh = this.getBatchLoader('highlightId')
this.batchGetFromArticle = ba.load.bind(ba)
this.batchGetFromHighlight = bh.load.bind(bh)
}
@logMethod
async getByUserAndParam<K extends keyof CreateSet>(
userId: ReactionData['userId'],
params: Record<K, ReactionData[K]>,
tx = this.kx
): Promise<ReactionData | null> {
const row: ReactionData | null = await tx(this.tableName)
.select()
.where({ userId })
.andWhere(params)
.andWhere('deleted', false)
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
async delete(
id: string,
tx: Knex.Transaction
): Promise<ReactionData | { error: DataModelError }> {
const [row]: ReactionData[] = await tx(this.tableName)
.update({ deleted: true })
.where({ id })
.returning(this.modelKeys)
if (!row) return { error: DataModelError.notFound }
this.loader.clear(id)
return row
}
}
export default ReactionModel

View file

@ -1,59 +0,0 @@
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* --------------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* user_article_id | uuid | | |
* highlight_id | uuid | | |
* highlight_reply_id | uuid | | |
* code | varchar(50) | | not null |
* deleted | boolean | | not null | false
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | |
* ```
* */
export interface ReactionData {
id: string
userId: string
userArticleId?: string | null
highlightId?: string | null
highlightReplyId?: string | null
code: string
deleted: boolean
createdAt: Date
updatedAt?: Date | null
}
export const keys = [
'id',
'userId',
'userArticleId',
'highlightId',
'highlightReplyId',
'code',
'deleted',
'createdAt',
'updatedAt',
] as const
export const defaultedKeys = [
'id',
'createdAt',
'updatedAt',
'deleted',
] as const
type DefaultedSet = PickTuple<ReactionData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<ReactionData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = ['code'] as const
export type UpdateSet = Partialize<PickTuple<ReactionData, typeof updateKeys>>

View file

@ -1,163 +0,0 @@
import {
CreateSet,
keys as modelKeys,
ParametersSet,
ReminderData,
UpdateSet,
} from './model'
import DataModel, { DataModelError } from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import { logMethod } from '../helpers'
import { ArticleData } from '../article/model'
import { UserArticleData } from '../links/model'
const JOIN_COLS = [
'links2.id',
'links2.slug',
'pages.title',
'pages.description',
'pages.author',
'pages.image',
'reminders.send_notification',
]
class ReminderModel extends DataModel<ReminderData, CreateSet, UpdateSet> {
public tableName = Table.REMINDER
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async setRemindersComplete(
userId: ReminderData['userId'],
remindAt: ReminderData['remindAt'],
tx = this.kx
): Promise<ReminderData | null> {
const [row]: ReminderData[] = await tx(this.tableName)
.where({ userId, remindAt })
.update({ status: 'COMPLETED' })
.returning(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getCreated(
id: ReminderData['id'],
tx = this.kx
): Promise<ReminderData | null> {
const row: ReminderData = await tx(this.tableName)
.select()
.where({ id, status: 'CREATED' })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getCreatedByParameters<K extends keyof ParametersSet>(
userId: ReminderData['userId'],
params: Record<K, ReminderData[K]>,
tx = this.kx
): Promise<ReminderData | null> {
const row: ReminderData = await tx(this.tableName)
.select()
.where({ userId: userId, status: 'CREATED' })
.andWhere(params)
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getByRequestId(
userId: string,
requestId: string,
tx = this.kx
): Promise<ReminderData | null> {
const row: ReminderData = await tx(this.tableName)
.select()
.where({ userId, elasticPageId: requestId })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async existByUserAndRemindAt(
userId: ReminderData['userId'],
remindAt: ReminderData['remindAt'],
tx = this.kx
): Promise<boolean> {
const row: ReminderData | null = await tx(this.tableName)
.select()
.where({ userId: userId, status: 'CREATED', remindAt: remindAt })
.first('id')
return !!row?.id
}
@logMethod
async getByUserAndRemindAt(
userId: ReminderData['userId'],
remindAt: ReminderData['remindAt'],
tx = this.kx
): Promise<(ReminderData & ArticleData & UserArticleData)[] | null> {
const rows: (ReminderData & ArticleData & UserArticleData)[] | null =
await tx(this.tableName)
.select(JOIN_COLS)
.leftJoin(Table.LINKS, 'links.id', 'reminders.link_id')
.leftJoin(
Table.ARTICLE_SAVING_REQUEST,
'article_saving_request.id',
'reminders.article_saving_request_id'
)
.leftJoin(Table.PAGES, function () {
this.on('pages.id ', '=', 'links.article_id')
this.orOn('pages.id', '=', 'article_saving_request.article_id')
})
.leftJoin(`${Table.LINKS} as links2`, 'links2.article_id', 'pages.id')
.where({
'reminders.user_id': userId,
'reminders.status': 'CREATED',
'reminders.remind_at': remindAt,
})
if (rows.length == 0) return null
return rows
}
@logMethod
async delete(
id: ReminderData['id'],
tx = this.kx
): Promise<ReminderData | { error: DataModelError }> {
const [row]: ReminderData[] = await tx(this.tableName)
.where({ id })
.update({ status: 'DELETED' })
.returning(this.modelKeys)
if (!row) return { error: DataModelError.notFound }
this.loader.clear(id)
return row
}
}
export default ReminderModel

View file

@ -1,74 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------------------+--------------------------+-----------+----------+----------------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* article_saving_request_id | uuid | | |
* link_id | uuid | | |
* task_name | text | | |
* type | reminder_type | | not null |
* status | reminder_status | | not null | 'CREATED'::reminder_status
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | |
* remind_at | timestamp with time zone | | not null |
* archive_until | boolean | | not null | false
* send_notification | boolean | | not null | true
* ```
* */
export interface ReminderData {
id: string
userId: string
articleSavingRequestId?: string
linkId?: string
archiveUntil?: boolean
sendNotification?: boolean
taskName?: string
type: string
status?: string
createdAt: Date
updatedAt?: Date
remindAt: Date
elasticPageId?: string
}
export const keys = [
'id',
'userId',
'articleSavingRequestId',
'linkId',
'archiveUntil',
'sendNotification',
'taskName',
'remindAt',
'status',
'createdAt',
'updatedAt',
'elasticPageId',
] as const
export const defaultedKeys = ['id', 'updatedAt', 'status'] as const
type DefaultedSet = PickTuple<ReminderData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<ReminderData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [
'taskName',
'remindAt',
'status',
'sendNotification',
'archiveUntil',
] as const
export type UpdateSet = PickTuple<ReminderData, typeof updateKeys>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<ReminderData, typeof getByParametersKeys>

View file

@ -1,31 +0,0 @@
import { Knex } from 'knex'
import { ReportType } from '../../generated/graphql'
interface ReportItem {
pageId: string
itemUrl: string
sharedBy: string
reportedBy: string | undefined
reportTypes: ReportType[]
reportComment: string
}
// Returns the ID of the report
export const createAbuseReport = async (
tx: Knex,
reportedBy: string | undefined,
input: ReportItem
): Promise<string> => {
const report = { ...input, reportedBy }
const result: string[] = await tx<ReportItem>(
'omnivore.abuse_reports'
).insert(report, ['id'])
if (!result || result.length !== 1) {
throw new Error('Unable to create abuse report.')
}
return result[0]
}

View file

@ -1,22 +0,0 @@
import { CreateSet, keys as modelKeys, TaskData, UpdateSet } from './model'
import DataModel from '../model'
import { Knex } from 'knex'
import { logMethod } from '../helpers'
class TaskModel extends DataModel<TaskData, CreateSet, UpdateSet> {
public tableName = 'omnivore.task'
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async create(set: CreateSet, tx?: Knex.Transaction): Promise<TaskData> {
if (tx) {
return super.create(set, tx)
}
return this.kx.transaction((tx) => super.create(set, tx))
}
}
export default TaskModel

View file

@ -1,33 +0,0 @@
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------------+--------------------------+------------+-------------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* title | text | | not null |
* created_by | uuid | | not null |
* created_at | timestamp with time zone | | not null |
* ```
* */
export interface TaskData {
id: string
title: string
createdBy: string
createdAt: Date
}
export const keys = ['id', 'title', 'createdBy', 'createdAt'] as const
export const defaultedKeys = ['id'] as const
type DefaultedSet = PickTuple<TaskData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<TaskData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = ['title'] as const
export type UpdateSet = PickTuple<TaskData, typeof updateKeys>

View file

@ -1,100 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
CreateSet,
keys as modelKeys,
UpdateSet,
UploadFileData,
} from './model'
import DataModel from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import { logMethod } from '../helpers'
class UploadFileDataModel extends DataModel<
UploadFileData,
CreateSet,
UpdateSet
> {
public tableName = Table.UPLOAD_FILES
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async create(set: CreateSet, tx?: Knex.Transaction): Promise<UploadFileData> {
if (tx) {
return super.create(set, tx)
}
return this.kx.transaction((tx) => super.create(set, tx))
}
@logMethod
async getWhere(
params: {
id?: UploadFileData['id']
userId?: UploadFileData['userId']
url?: UploadFileData['url']
},
tx = this.kx
): Promise<UploadFileData | null> {
const row: UploadFileData | null = await tx(this.tableName)
.select(this.modelKeys)
.where(params)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async setFileUploadComplete(
id: UploadFileData['id'],
tx = this.kx
): Promise<UploadFileData | null> {
const row: UploadFileData | null = await tx(this.tableName)
.select()
.where({ id })
.update({ status: 'COMPLETED' })
.returning(this.modelKeys)
.limit(2)
.then((result) => {
if (result.length == 1) {
return result[0]
}
throw Error('Should never return multiple rows')
})
if (row?.id) {
this.loader.prime(row.id, row)
}
return row
}
async uploadFileForUserAndArticle(
userId: string,
articleId: string
): Promise<UploadFileData | null> {
const row: UploadFileData | null = await this.kx(Table.PAGES)
.select(this.modelKeys.map((k) => `${Table.UPLOAD_FILES}.${k}`))
.join(Table.UPLOAD_FILES, 'upload_files.id', '=', 'pages.upload_file_id')
.where({ 'upload_files.userId': userId, 'pages.id': articleId })
.limit(1)
.first()
return row
}
async uploadFileForArticle(
articleId: string
): Promise<UploadFileData | null> {
const row: UploadFileData | null = await this.kx(Table.PAGES)
.select(this.modelKeys.map((k) => `${Table.UPLOAD_FILES}.${k}`))
.join(Table.UPLOAD_FILES, 'upload_files.id', '=', 'pages.upload_file_id')
.where({ 'pages.id': articleId })
.limit(1)
.first()
return row
}
}
export default UploadFileDataModel

View file

@ -1,58 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* -------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* url | text | | not null |
* file_name | text | | not null |
* content_type| text | | not null |
* status | upload_status_type (text)| | not null |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* ```
* */
export interface UploadFileData {
id: string
userId: string
url: string
fileName: string
contentType: string
status: string
createdAt: Date
updatedAt: Date
}
export const keys = [
'id',
'userId',
'url',
'fileName',
'contentType',
'status',
'createdAt',
'updatedAt',
] as const
export const defaultedKeys = ['id', 'createdAt', 'updatedAt'] as const
type DefaultedSet = PickTuple<UploadFileData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UploadFileData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = ['url', 'status'] as const
export type UpdateSet = PickTuple<UploadFileData, typeof updateKeys>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<
UploadFileData,
typeof getByParametersKeys
>

View file

@ -1,349 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
CreateSet,
keys as modelKeys,
ProfileCreateSet,
profileKeys,
ProfileUpdateSet,
UpdateSet,
UserData,
} from './model'
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
import { Knex } from 'knex'
import {
ENABLE_DB_REQUEST_LOGGING,
globalCounter,
logMethod,
logger,
} from '../helpers'
import { Table } from '../../utils/dictionary'
import DataLoader from 'dataloader'
import { Partialize } from '../../util'
import { Profile } from '../../generated/graphql'
import { kx as knexConfig } from './../knex_config'
const TOP_USERS = [
'jacksonh',
'nat',
'luis',
'satindar',
'malandrina',
'patrick',
'alexgutjahr',
]
class UserModel extends DataModel<UserData, CreateSet, UpdateSet> {
public tableName = Table.USER
protected modelKeys = modelKeys
constructor(kx: Knex = knexConfig, cache = true) {
super(kx, cache)
// override DataModel's base class dataloader to include profile join in user query
this.loader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
}
try {
const rows: UserData[] = await this.kx({ u: this.tableName })
.select([
...this.modelKeys.map((k) => `u.${k}`),
this.kx.raw('to_jsonb(p) as profile'),
])
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.whereIn('u.id', keys)
.limit(MAX_RECORDS_LIMIT)
const keyMap: Record<string, UserData> = {}
for (const row of rows) {
if (row.id in keyMap) continue
keyMap[row.id] = row
}
const result = keys.map((key) => keyMap[key])
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
} catch (e) {
logger.error('DataModel error: ', e)
throw e
}
},
{ cache }
)
this.get = this.loader.load.bind(this.loader)
this.getMany = this.loader.loadMany.bind(this.loader)
}
@logMethod
async getWhere(
params: {
email?: UserData['email']
source?: UserData['source']
username?: UserData['profile']['username']
sourceUserId?: UserData['sourceUserId']
'p.username'?: UserData['profile']['username']
},
tx = this.kx
): Promise<UserData | null> {
// rewrite username key if it exists to use it with joined profile table
if ('username' in params) {
params['p.username'] = params.username
delete params.username
}
const row: UserData | null = await tx({ u: this.tableName })
.select([
...this.modelKeys.map((k) => `u.${k}`),
this.kx.raw('to_jsonb(p) as profile'),
])
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.where(params)
.first()
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async exists(
params: {
email?: UserData['email']
username?: UserData['profile']['username']
},
tx = this.kx
): Promise<boolean> {
const row: UserData | null = await tx({ u: this.tableName })
.select('u.id')
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.where('email', params.email || '')
.orWhere('p.username', params.username || '')
.first()
return !!row?.id
}
@logMethod
async getSharedHighlightsStats(
userId: string,
tx = this.kx
): Promise<{ sharedHighlightsCount: number; sharedNotesCount: number }> {
const row = await tx('omnivore.highlight as h2')
.select([
tx.raw('count(h2.id) as shared_highlights_count'),
tx.raw('count(h2.annotation) as shared_notes_count'),
])
.whereRaw('h2.user_id::text = ?', [userId])
.andWhere('h2.deleted', '=', tx.raw('FALSE'))
.whereNotNull('h2.shared_at')
.first()
if (!row) {
return { sharedHighlightsCount: 0, sharedNotesCount: 0 }
}
this.loader.prime(row.id, row)
return row
}
@logMethod
async getUserDetails(
viewerId: string | undefined,
userId: string,
tx = this.kx
): Promise<
| (UserData & {
followersCount: number
friendsCount: number
viewerIsFollowing: boolean
})
| null
> {
/*
* We join the user table with the user_friends table twice, once to fetch
* the list of friends, and another join to query list of followers. We
* group the results by user ID, aggregate the friends and followers
* results using postgres array operators. We use coalesce() to return 0
* when the results array is null.
*/
const row = await tx({ u: this.tableName })
.select([
'u.*',
this.kx.raw(
'coalesce(array_length(array_remove(array_agg(DISTINCT omnivore.user_friends.friend_user_id), null), 1), 0) as friends_count'
),
this.kx.raw(
'coalesce(array_length(array_remove(array_agg(DISTINCT user_followers.user_id), null), 1), 0) as followers_count'
),
this.kx.raw(
viewerId
? 'coalesce(? = ANY(array_agg(DISTINCT user_followers.user_id)), false) as viewer_is_following'
: '? as _unused',
[viewerId || '']
),
this.kx.raw('to_jsonb(p) as profile'),
])
.leftJoin('omnivore.user_friends', function () {
this.on(
tx.raw('omnivore.user_friends.user_id::text = ?', [userId])
).andOn('omnivore.user_friends.user_id', '=', 'u.id')
})
.leftJoin('omnivore.user_friends as user_followers', function () {
this.on(tx.raw('user_followers.friend_user_id::text = ?', [userId]))
})
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.whereRaw('u.id::text = ?', [userId])
.groupBy('u.id')
.groupBy('p.id')
.first()
if (!row) {
return null
}
this.loader.prime(row.id, row)
return row
}
@logMethod
async getTopUsers(
userId: string,
tx = this.kx
): Promise<
| (UserData & {
followersCount: number
friendsCount: number
isFriend: boolean
})[]
| null
> {
const rows = await tx({ u: this.tableName })
.select(['u.*', this.kx.raw('to_jsonb(p) as profile')])
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.whereIn('p.username', TOP_USERS)
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async getUserFollowersList(
userId: string,
tx = this.kx
): Promise<UserData[]> {
const rows: UserData[] = await tx({ u: this.tableName })
.select([`u.*`, this.kx.raw('to_jsonb(p) as profile')])
.leftJoin(Table.USER_FRIEND, `user_id`, '=', `u.id`)
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.where(`friend_user_id`, '=', userId)
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async getUserFollowingList(
userId: string,
tx = this.kx
): Promise<UserData[]> {
const rows: UserData[] = await tx({ u: this.tableName })
.select([`u.*`, this.kx.raw('to_jsonb(p) as profile')])
.leftJoin(Table.USER_FRIEND, `friend_user_id`, '=', `u.id`)
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
.where(`user_friends.user_id`, '=', userId)
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async createUserWithProfile(
createSet: CreateSet,
username: string,
bio?: string
): Promise<UserData> {
return this.kx.transaction(async (tx) => {
const userData = await this.create(createSet, tx)
await this.createProfile({ username, userId: userData.id, bio }, tx)
return userData
})
}
@logMethod
async create(set: CreateSet, tx?: Knex.Transaction): Promise<UserData> {
if (tx) {
return super.create(set, tx)
}
return this.kx.transaction((tx) => super.create(set, tx))
}
@logMethod
async update(
userId: string,
set: UpdateSet,
tx?: Knex.Transaction
): Promise<UserData> {
if (tx) {
return super.update(userId, set, tx)
}
return this.kx.transaction((tx) => super.update(userId, set, tx))
}
@logMethod
async createProfile(
set: ProfileCreateSet,
tx?: Knex.Transaction
): Promise<Profile> {
if (tx) {
const [profile] = (await tx(Table.USER_PROFILE)
.insert(set)
.returning(profileKeys)) as Profile[]
return profile
}
return this.kx.transaction(async (tx) => {
const [profile] = (await tx(Table.USER_PROFILE)
.insert(set)
.returning(profileKeys)) as Profile[]
return profile
})
}
@logMethod
async updateProfile(
userId: string,
set: Partialize<ProfileUpdateSet>,
tx?: Knex.Transaction
): Promise<Profile> {
if (tx) {
const [profile]: Profile[] = await tx(Table.USER_PROFILE)
.update(set)
.where({ userId })
.returning(profileKeys)
return profile
}
return this.kx.transaction((tx) => this.updateProfile(userId, set, tx))
}
@logMethod
async delete(
userId: string,
tx?: Knex.Transaction
): Promise<UserData | { error: DataModelError }> {
if (tx) {
return super.delete(userId, tx)
}
return this.kx.transaction((tx) => super.delete(userId, tx))
}
}
export default UserModel

View file

@ -1,116 +0,0 @@
import { exclude, Partialize, PickTuple } from '../../util'
// Table "omnivore.user"
// Column | Type | Collation | Nullable | Default
// ----------------+--------------------------+-----------+----------+---------------------------------------
// id | uuid | | not null | uuid_generate_v1mc()
// first_name | text | | |
// last_name | text | | |
// source | registration_type | | not null |
// email | text | | |
// phone | text | | |
// source_user_id | text | | not null |
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
// updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
// Table "omnivore.user_profile"
// Column | Type | Collation | Nullable | Default
// -------------+--------------------------+-----------+----------+----------------------
// id | uuid | | not null | uuid_generate_v1mc()
// username | text | | not null |
// private | boolean | | not null | false
// bio | text | | |
// picture_url | text | | |
// user_id | uuid | | not null |
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
// updated_at | timestamp with time zone | | |
export interface UserData {
id: string
name: string
source: string
email?: string | null
phone?: string | null
sourceUserId: string
createdAt: Date
// snake_case here because our knex case transformation doesn't support nested objects
profile: {
id: string
username: string
bio?: string | null
picture_url?: string | null
private: boolean
}
password?: string | null
status?: StatusType
}
export enum RegistrationType {
Google = 'GOOGLE',
Apple = 'APPLE',
Email = 'EMAIL',
}
export enum StatusType {
Active = 'ACTIVE',
Pending = 'PENDING',
}
export const keys = [
'id',
'name',
'source',
'email',
'phone',
'sourceUserId',
'createdAt',
'password',
'status',
] as const
export const defaultedKeys = ['id', 'createdAt'] as const
type DefaultedSet = PickTuple<UserData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UserData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [
'name',
'sourceUserId',
'source',
'password',
] as const
export type UpdateSet = PickTuple<UserData, typeof updateKeys>
// Profile-related types
export const profileKeys = [
'id',
'username',
'bio',
'pictureUrl',
'private',
'userId',
] as const
export const createProfileKeys = exclude(profileKeys, ['id'])
export type ProfileCreateSet = PickTuple<
UserData['profile'],
typeof createProfileKeys
>
export const profileUpdateKeys = [
'username',
'bio',
'picture_url',
'private',
] as const
export type ProfileUpdateSet = PickTuple<
UserData['profile'],
typeof profileUpdateKeys
>

View file

@ -1,110 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
CreateSet,
keys as modelKeys,
UpdateSet,
UserFriendData,
} from './model'
import DataModel, { MAX_RECORDS_LIMIT } from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import {
ENABLE_DB_REQUEST_LOGGING,
globalCounter,
logMethod,
logger,
} from '../helpers'
import DataLoader from 'dataloader'
class UserFriendModel extends DataModel<UserFriendData, CreateSet, UpdateSet> {
public tableName = Table.USER_FRIEND
protected modelKeys = modelKeys
protected getFriendsLoader: DataLoader<string, string[]>
getFriends: DataLoader<string, string[]>['load']
constructor(kx: Knex, cache = true) {
super(kx, cache)
this.getFriendsLoader = new DataLoader(
async (keys) => {
if (ENABLE_DB_REQUEST_LOGGING) {
globalCounter.log(
this.tableName,
'user_friends_loader',
JSON.stringify(keys)
)
}
const rows: UserFriendData[] = await this.kx(this.tableName)
.select(this.modelKeys)
.whereIn('userId', keys)
.limit(MAX_RECORDS_LIMIT)
const keyMap: Record<string, string[]> = {}
for (const row of rows) {
keyMap[row.userId] = [...(keyMap[row.userId] || []), row.friendUserId]
}
const result = keys.map((userId) => keyMap[userId] || [])
// logger.debug('\n\n\n\n\nResult for userId_articleId_load', { keys, result });
if (result.length !== keys.length) {
logger.error('DataModel error: count mismatch ', keys, result)
}
return result
},
{ cache }
)
this.getFriends = this.getFriendsLoader.load.bind(this.getFriendsLoader)
}
@logMethod
async getByUserFriendId(
userId: UserFriendData['userId'],
friendUserId: UserFriendData['friendUserId'],
tx = this.kx
): Promise<UserFriendData | null> {
const row: UserFriendData | null = await tx(this.tableName)
.select()
.where({ userId, friendUserId })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async getFollowers(
userId: UserFriendData['userId'],
tx = this.kx
): Promise<UserFriendData[]> {
const rows: UserFriendData[] = await tx(this.tableName)
.where({ friendUserId: userId })
.select(this.modelKeys)
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
@logMethod
async getByFriendIds(
userId: string,
followersIds: string[],
tx = this.kx
): Promise<UserFriendData[]> {
const rows: UserFriendData[] = await tx(this.tableName)
.select(this.modelKeys)
.whereIn('friend_user_id', followersIds)
.andWhere({ userId })
.limit(MAX_RECORDS_LIMIT)
for (const row of rows) {
this.loader.prime(row.id, row)
}
return rows
}
}
export default UserFriendModel

View file

@ -1,41 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* friend_user_id| uuid | | not null |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* ```
* */
export interface UserFriendData {
id: string
userId: string
friendUserId: string
createdAt: Date
}
export const keys = ['id', 'userId', 'friendUserId', 'createdAt'] as const
export const defaultedKeys = ['id', 'createdAt'] as const
type DefaultedSet = PickTuple<UserFriendData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UserFriendData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [] as const
export type UpdateSet = PickTuple<UserFriendData, typeof updateKeys>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<
UserFriendData,
typeof getByParametersKeys
>

View file

@ -1,69 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
CreateSet,
keys as modelKeys,
UpdateSet,
UserPersonalizationData,
} from './model'
import DataModel from '../model'
import { Knex } from 'knex'
import { Table } from '../../utils/dictionary'
import { camelCase } from 'voca'
import { logMethod } from '../helpers'
class UserPersonalizationModel extends DataModel<
UserPersonalizationData,
CreateSet,
UpdateSet
> {
public tableName = Table.USER_PERSONALIZATION
protected modelKeys = modelKeys
constructor(kx: Knex, cache = true) {
super(kx, cache)
}
@logMethod
async getByUserId(
userId: UserPersonalizationData['userId'],
tx = this.kx
): Promise<UserPersonalizationData | null> {
const row: UserPersonalizationData | null = await tx(this.tableName)
.select()
.where({ userId })
.first(this.modelKeys)
if (!row) return null
this.loader.prime(row.id, row)
return row
}
@logMethod
async upsert(
data: CreateSet,
tx = this.kx
): Promise<UserPersonalizationData> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { userId, id, ...updateSet } = data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const row = {} as any
const result = (
await tx.raw(
`? ON CONFLICT (user_id)
DO ?`,
[
tx(this.tableName).insert(data),
tx.update(updateSet).returning(this.modelKeys),
]
)
).rows[0]
for (const [key, value] of Object.entries(result)) {
row[camelCase(key)] = value
}
this.loader.prime(row.id, row)
return row
}
}
export default UserPersonalizationModel

View file

@ -1,71 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { exclude, Partialize, PickTuple } from '../../util'
/**
* ```
* Column | Type | Collation | Nullable | Default
* ---------------------+--------------------------+-----------+----------+----------------------
* id | uuid | | not null | uuid_generate_v1mc()
* user_id | uuid | | not null |
* font_size | integer | | |
* font_family | text | | |
* theme | text | | |
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
* margin | integer | | |
* library_layout_type | text | | |
* library_sort_order | text | | |
* ```
* */
export interface UserPersonalizationData {
id: string
userId: string
fontFamily?: string | null
fontSize?: number | null
margin?: number | null
theme?: string | null
libraryLayoutType?: string | null
librarySortOrder?: string | null
createdAt: Date
updatedAt: Date
}
export const keys = [
'id',
'userId',
'fontSize',
'fontFamily',
'margin',
'theme',
'libraryLayoutType',
'librarySortOrder',
'createdAt',
'updatedAt',
] as const
export const defaultedKeys = ['id', 'createdAt', 'updatedAt'] as const
type DefaultedSet = PickTuple<UserPersonalizationData, typeof defaultedKeys>
export const createKeys = exclude(keys, defaultedKeys)
export type CreateSet = PickTuple<UserPersonalizationData, typeof createKeys> &
Partialize<DefaultedSet>
export const updateKeys = [
'fontSize',
'fontFamily',
'margin',
'theme',
'libraryLayoutType',
'librarySortOrder',
] as const
export type UpdateSet = PickTuple<UserPersonalizationData, typeof updateKeys>
export const getByParametersKeys = exclude(keys, ['id'] as const)
export type ParametersSet = PickTuple<
UserPersonalizationData,
typeof getByParametersKeys
>

View file

@ -1,312 +0,0 @@
import { errors } from '@elastic/elasticsearch'
import { EntityType } from '../pubsub'
import { SortBy, SortOrder, SortParams } from '../utils/search'
import { client, INDEX_ALIAS, logger } from './index'
import {
Highlight,
Page,
PageContext,
PageType,
SearchItem,
SearchResponse,
} from './types'
export const addHighlightToPage = async (
id: string,
highlight: Highlight,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id,
body: {
script: {
source: `if (ctx._source.highlights == null) {
ctx._source.highlights = [params.highlight]
} else {
ctx._source.highlights.add(params.highlight)
}
ctx._source.updatedAt = params.highlight.updatedAt`,
lang: 'painless',
params: {
highlight,
},
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
await ctx.pubsub.entityCreated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
)
return true
} catch (e) {
if (
e instanceof errors.ResponseError &&
e.message === 'document_missing_exception'
) {
logger.info('page has been deleted', id)
return false
}
logger.error('failed to add highlight to a page in elastic', e)
return false
}
}
export const getHighlightById = async (
id: string
): Promise<Highlight | undefined> => {
try {
const { body } = await client.search({
index: INDEX_ALIAS,
body: {
query: {
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': id,
},
},
inner_hits: {},
},
},
_source: false,
},
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (body.hits.total.value === 0) {
return undefined
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return
return body.hits.hits[0].inner_hits.highlights.hits.hits[0]._source
} catch (e) {
logger.error('failed to get highlight from a page in elastic', e)
return undefined
}
}
export const deleteHighlight = async (
highlightId: string,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlightId);
ctx._source.updatedAt = params.updatedAt`,
lang: 'painless',
params: {
highlightId: highlightId,
updatedAt: new Date(),
},
},
query: {
bool: {
filter: [
{
term: {
userId: ctx.uid,
},
},
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlightId,
},
},
},
},
],
},
},
},
refresh: ctx.refresh,
})
body.updated > 0 &&
(await ctx.pubsub.entityDeleted(
EntityType.HIGHLIGHT,
highlightId,
ctx.uid
))
return true
} catch (e) {
logger.error('failed to delete a highlight in elastic', e)
return false
}
}
export const searchHighlights = async (
args: {
from?: number
size?: number
sort?: SortParams
query?: string
},
userId: string
): Promise<[SearchItem[], number] | undefined> => {
try {
const { from = 0, size = 10, sort, query } = args
const sortOrder = sort?.order || SortOrder.DESCENDING
// default sort by updatedAt
const sortField =
sort?.by === SortBy.SCORE ? SortBy.SCORE : 'highlights.updatedAt'
const searchBody = {
query: {
bool: {
filter: [
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.userId': userId,
},
},
},
},
],
should: [
{
multi_match: {
query: query || '',
fields: [
'highlights.quote^5',
'title^3',
'description^2',
'content',
],
},
},
],
minimum_should_match: query ? 1 : 0,
},
},
sort: [
'_score',
{
[sortField]: {
order: sortOrder,
nested: {
path: 'highlights',
},
},
},
],
from,
size,
_source: [
'title',
'slug',
'url',
'savedAt',
'highlights',
'readingProgressPercent',
'readingProgressAnchorIndex',
],
}
logger.info('searching highlights in elastic', searchBody)
const response = await client.search<SearchResponse<Page>>({
index: INDEX_ALIAS,
body: searchBody,
})
if (response.body.hits.total.value === 0) {
return [[], 0]
}
const results: SearchItem[] = []
response.body.hits.hits.forEach((hit) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
hit._source.highlights?.forEach((highlight) => {
results.push({
...highlight,
...hit._source,
pageId: hit._id,
pageType: PageType.Highlights,
})
})
})
return [results, response.body.hits.total.value]
} catch (e) {
logger.error('failed to search highlights in elastic', e)
return undefined
}
}
export const updateHighlight = async (
highlight: Highlight,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlight.id);
ctx._source.highlights.add(params.highlight);
ctx._source.updatedAt = params.highlight.updatedAt`,
lang: 'painless',
params: {
highlight,
},
},
query: {
bool: {
filter: [
{
term: {
userId: ctx.uid,
},
},
{
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlight.id,
},
},
},
},
],
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed',
})
body.updated > 0 &&
(await ctx.pubsub.entityUpdated<Highlight>(
EntityType.HIGHLIGHT,
highlight,
ctx.uid
))
return true
} catch (e) {
logger.error('failed to update highlight in elastic', e)
return false
}
}

View file

@ -1,66 +0,0 @@
import { Client } from '@elastic/elasticsearch'
import { readFileSync } from 'fs'
import { env } from '../env'
import { buildLogger } from '../utils/logger'
export const INDEX_ALIAS = 'pages_alias'
export const client = new Client({
node: env.elastic.url,
maxRetries: 3,
requestTimeout: 50000,
auth: {
username: env.elastic.username,
password: env.elastic.password,
},
})
const INDEX_NAME = 'pages'
export const logger = buildLogger('elasticsearch')
const createIndex = async (): Promise<void> => {
// read index settings from file
const indexSettings = readFileSync(
__dirname + '/../../../db/elastic_migrations/index_settings.json',
'utf8'
)
// create index
await client.indices.create({
index: INDEX_NAME,
body: indexSettings,
})
}
export const initElasticsearch = async (): Promise<void> => {
try {
const response = await client.info()
logger.info('elastic info: ', response)
// check if index exists
const { body: indexExists } = await client.indices.exists({
index: INDEX_ALIAS,
})
if (!indexExists) {
logger.info('creating index...')
await createIndex()
logger.info('refreshing index...')
await refreshIndex()
}
logger.info('elastic client is ready')
} catch (e) {
logger.error('failed to init elasticsearch', e)
throw e
}
}
export const refreshIndex = async (): Promise<void> => {
try {
const { body } = await client.indices.refresh({
index: INDEX_ALIAS,
})
logger.info('elastic refresh: ', body)
} catch (e) {
logger.error('failed to refresh elastic index', e)
throw e
}
}

View file

@ -1,333 +0,0 @@
import { errors } from '@elastic/elasticsearch'
import { EntityType } from '../pubsub'
import { client, INDEX_ALIAS, logger } from './index'
import { Label, PageContext } from './types'
export const addLabelInPage = async (
pageId: string,
label: Label,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id: pageId,
body: {
script: {
source: `if (ctx._source.labels == null) {
ctx._source.labels = [params.label];
ctx._source.updatedAt = params.updatedAt
} else if (!ctx._source.labels.any(label -> label.name == params.label.name)) {
ctx._source.labels.add(params.label);
ctx._source.updatedAt = params.updatedAt
} else { ctx.op = 'none' }`,
lang: 'painless',
params: {
label: label,
updatedAt: new Date(),
},
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
return true
} catch (e) {
if (
e instanceof errors.ResponseError &&
e.message === 'document_missing_exception'
) {
logger.info('page has been deleted', pageId)
return false
}
logger.error('failed to add a label in elastic', e)
return false
}
}
export const updateLabelsInPage = async (
pageId: string,
labels: Label[],
ctx: PageContext,
labelsToAdd?: Label[]
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id: pageId,
body: {
doc: {
labels: labels,
updatedAt: new Date(),
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result === 'noop') return true
if (body.result !== 'updated') return false
if (labelsToAdd) {
// publish labels to be added
await Promise.all(
labelsToAdd.map((label) =>
ctx.pubsub.entityCreated<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
)
)
}
return true
} catch (e) {
if (
e instanceof errors.ResponseError &&
e.message === 'document_missing_exception'
) {
logger.info('page has been deleted', pageId)
return false
}
logger.error('failed to update labels in elastic', e)
return false
}
}
export const deleteLabel = async (
label: string,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `if (ctx._source.highlights != null) {
for (h in ctx._source.highlights) {
if (h.labels != null) {
h.labels.removeIf(l -> l.name == params.label)
}
}
}
if (ctx._source.labels != null) {
ctx._source.labels.removeIf(label -> label.name == params.label);
}`,
lang: 'painless',
params: {
label: label,
},
},
query: {
bool: {
must: {
term: {
userId: ctx.uid,
},
},
should: [
{
nested: {
path: 'labels',
query: {
term: {
'labels.name': label,
},
},
},
},
{
nested: {
path: 'highlights',
query: {
nested: {
path: 'highlights.labels',
query: {
term: {
'highlights.labels.name': label,
},
},
},
},
},
},
],
minimum_should_match: 1,
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed', // ignore conflicts
})
body.updated > 0 &&
(await ctx.pubsub.entityDeleted(EntityType.LABEL, label, ctx.uid))
return true
} catch (e) {
logger.error('failed to delete a label in elastic', e)
return false
}
}
export const updateLabel = async (
label: Label,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `if (ctx._source.labels != null) {
for (l in ctx._source.labels) {
if (l.id == params.label.id) {
l.name = params.label.name;
l.color = params.label.color;
l.description = params.description;
}
}
}
if (ctx._source.highlights != null) {
for (h in ctx._source.highlights) {
if (h.labels != null) {
for (l in h.labels) {
if (l.id == params.label.id) {
l.name = params.label.name;
l.color = params.label.color;
l.description = params.description;
}
}
}
}
}`,
lang: 'painless',
params: {
label: label,
},
},
query: {
bool: {
must: {
term: {
userId: ctx.uid,
},
},
should: [
{
nested: {
path: 'labels',
query: {
term: {
'labels.id': label.id,
},
},
},
},
{
nested: {
path: 'highlights',
query: {
nested: {
path: 'highlights.labels',
query: {
term: {
'highlights.labels.id': label.id,
},
},
},
},
},
},
],
minimum_should_match: 1,
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed', // ignore conflicts
requests_per_second: 500, // throttle the requests
slices: 'auto', // parallelize the requests
})
body.updated > 0 &&
(await ctx.pubsub.entityUpdated(EntityType.LABEL, label, ctx.uid))
return true
} catch (e) {
logger.error('failed to update label in elastic', e)
return false
}
}
export const setLabelsForHighlight = async (
highlightId: string,
labels: Label[],
ctx: PageContext,
labelsToAdd?: Label[]
): Promise<boolean> => {
try {
const { body } = await client.updateByQuery({
index: INDEX_ALIAS,
body: {
script: {
source: `ctx._source.highlights.find(h -> params.highlightId == h.id).labels = params.labels;
ctx._source.updatedAt = params.updatedAt`,
lang: 'painless',
params: {
highlightId,
labels,
updatedAt: new Date(),
},
},
query: {
nested: {
path: 'highlights',
query: {
term: {
'highlights.id': highlightId,
},
},
},
},
},
refresh: ctx.refresh,
conflicts: 'proceed', // ignore conflicts
})
if (labelsToAdd) {
// publish labels to be added
await Promise.all(
labelsToAdd.map((label) =>
ctx.pubsub.entityCreated<Label & { highlightId: string }>(
EntityType.LABEL,
{ highlightId, ...label },
ctx.uid
)
)
)
}
return true
} catch (e) {
if (
e instanceof errors.ResponseError &&
e.message === 'document_missing_exception'
) {
logger.info('highlight has been deleted', highlightId)
return false
}
logger.error('failed to set labels for highlight in elastic', e)
return false
}
}

View file

@ -1239,20 +1239,14 @@ export type Mutation = {
createArticleSavingRequest: CreateArticleSavingRequestResult;
createGroup: CreateGroupResult;
createHighlight: CreateHighlightResult;
createHighlightReply: CreateHighlightReplyResult;
createLabel: CreateLabelResult;
createNewsletterEmail: CreateNewsletterEmailResult;
createReaction: CreateReactionResult;
createReminder: CreateReminderResult;
deleteAccount: DeleteAccountResult;
deleteFilter: DeleteFilterResult;
deleteHighlight: DeleteHighlightResult;
deleteHighlightReply: DeleteHighlightReplyResult;
deleteIntegration: DeleteIntegrationResult;
deleteLabel: DeleteLabelResult;
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteReaction: DeleteReactionResult;
deleteReminder: DeleteReminderResult;
deleteRule: DeleteRuleResult;
deleteWebhook: DeleteWebhookResult;
generateApiKey: GenerateApiKeyResult;
@ -1279,14 +1273,11 @@ export type Mutation = {
setBookmarkArticle: SetBookmarkArticleResult;
setDeviceToken: SetDeviceTokenResult;
setFavoriteArticle: SetFavoriteArticleResult;
setFollow: SetFollowResult;
setIntegration: SetIntegrationResult;
setLabels: SetLabelsResult;
setLabelsForHighlight: SetLabelsResult;
setLinkArchived: ArchiveLinkResult;
setRule: SetRuleResult;
setShareArticle: SetShareArticleResult;
setShareHighlight: SetShareHighlightResult;
setUserPersonalization: SetUserPersonalizationResult;
setWebhook: SetWebhookResult;
subscribe: SubscribeResult;
@ -1294,12 +1285,8 @@ export type Mutation = {
updateEmail: UpdateEmailResult;
updateFilter: UpdateFilterResult;
updateHighlight: UpdateHighlightResult;
updateHighlightReply: UpdateHighlightReplyResult;
updateLabel: UpdateLabelResult;
updateLinkShareInfo: UpdateLinkShareInfoResult;
updatePage: UpdatePageResult;
updateReminder: UpdateReminderResult;
updateSharedComment: UpdateSharedCommentResult;
updateSubscription: UpdateSubscriptionResult;
updateUser: UpdateUserResult;
updateUserProfile: UpdateUserProfileResult;
@ -1342,26 +1329,11 @@ export type MutationCreateHighlightArgs = {
};
export type MutationCreateHighlightReplyArgs = {
input: CreateHighlightReplyInput;
};
export type MutationCreateLabelArgs = {
input: CreateLabelInput;
};
export type MutationCreateReactionArgs = {
input: CreateReactionInput;
};
export type MutationCreateReminderArgs = {
input: CreateReminderInput;
};
export type MutationDeleteAccountArgs = {
userID: Scalars['ID'];
};
@ -1377,11 +1349,6 @@ export type MutationDeleteHighlightArgs = {
};
export type MutationDeleteHighlightReplyArgs = {
highlightReplyId: Scalars['ID'];
};
export type MutationDeleteIntegrationArgs = {
id: Scalars['ID'];
};
@ -1397,16 +1364,6 @@ export type MutationDeleteNewsletterEmailArgs = {
};
export type MutationDeleteReactionArgs = {
id: Scalars['ID'];
};
export type MutationDeleteReminderArgs = {
id: Scalars['ID'];
};
export type MutationDeleteRuleArgs = {
id: Scalars['ID'];
};
@ -1532,11 +1489,6 @@ export type MutationSetFavoriteArticleArgs = {
};
export type MutationSetFollowArgs = {
input: SetFollowInput;
};
export type MutationSetIntegrationArgs = {
input: SetIntegrationInput;
};
@ -1562,16 +1514,6 @@ export type MutationSetRuleArgs = {
};
export type MutationSetShareArticleArgs = {
input: SetShareArticleInput;
};
export type MutationSetShareHighlightArgs = {
input: SetShareHighlightInput;
};
export type MutationSetUserPersonalizationArgs = {
input: SetUserPersonalizationInput;
};
@ -1608,36 +1550,16 @@ export type MutationUpdateHighlightArgs = {
};
export type MutationUpdateHighlightReplyArgs = {
input: UpdateHighlightReplyInput;
};
export type MutationUpdateLabelArgs = {
input: UpdateLabelInput;
};
export type MutationUpdateLinkShareInfoArgs = {
input: UpdateLinkShareInfoInput;
};
export type MutationUpdatePageArgs = {
input: UpdatePageInput;
};
export type MutationUpdateReminderArgs = {
input: UpdateReminderInput;
};
export type MutationUpdateSharedCommentArgs = {
input: UpdateSharedCommentInput;
};
export type MutationUpdateSubscriptionArgs = {
input: UpdateSubscriptionInput;
};
@ -1794,12 +1716,8 @@ export type Query = {
apiKeys: ApiKeysResult;
article: ArticleResult;
articleSavingRequest: ArticleSavingRequestResult;
articles: ArticlesResult;
deviceTokens: DeviceTokensResult;
feedArticles: FeedArticlesResult;
filters: FiltersResult;
getFollowers: GetFollowersResult;
getFollowing: GetFollowingResult;
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
@ -1809,11 +1727,9 @@ export type Query = {
newsletterEmails: NewsletterEmailsResult;
recentEmails: RecentEmailsResult;
recentSearches: RecentSearchesResult;
reminder: ReminderResult;
rules: RulesResult;
search: SearchResult;
sendInstallInstructions: SendInstallInstructionsResult;
sharedArticle: SharedArticleResult;
subscriptions: SubscriptionsResult;
typeaheadSearch: TypeaheadSearchResult;
updatesSince: UpdatesSinceResult;
@ -1838,39 +1754,6 @@ export type QueryArticleSavingRequestArgs = {
};
export type QueryArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
includePending?: InputMaybe<Scalars['Boolean']>;
query?: InputMaybe<Scalars['String']>;
sharedOnly?: InputMaybe<Scalars['Boolean']>;
sort?: InputMaybe<SortParams>;
};
export type QueryFeedArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
sharedByUser?: InputMaybe<Scalars['ID']>;
sort?: InputMaybe<SortParams>;
};
export type QueryGetFollowersArgs = {
userId?: InputMaybe<Scalars['ID']>;
};
export type QueryGetFollowingArgs = {
userId?: InputMaybe<Scalars['ID']>;
};
export type QueryReminderArgs = {
linkId: Scalars['ID'];
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -1885,13 +1768,6 @@ export type QuerySearchArgs = {
};
export type QuerySharedArticleArgs = {
selectedHighlightId?: InputMaybe<Scalars['String']>;
slug: Scalars['String'];
username: Scalars['String'];
};
export type QuerySubscriptionsArgs = {
sort?: InputMaybe<SortParams>;
type?: InputMaybe<SubscriptionType>;
@ -2457,7 +2333,7 @@ export type SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArti
export type SetFavoriteArticleSuccess = {
__typename?: 'SetFavoriteArticleSuccess';
favoriteArticle: Article;
success: Scalars['Boolean'];
};
export type SetFollowError = {
@ -5235,20 +5111,14 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
createArticleSavingRequest?: Resolver<ResolversTypes['CreateArticleSavingRequestResult'], ParentType, ContextType, RequireFields<MutationCreateArticleSavingRequestArgs, 'input'>>;
createGroup?: Resolver<ResolversTypes['CreateGroupResult'], ParentType, ContextType, RequireFields<MutationCreateGroupArgs, 'input'>>;
createHighlight?: Resolver<ResolversTypes['CreateHighlightResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightArgs, 'input'>>;
createHighlightReply?: Resolver<ResolversTypes['CreateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightReplyArgs, 'input'>>;
createLabel?: Resolver<ResolversTypes['CreateLabelResult'], ParentType, ContextType, RequireFields<MutationCreateLabelArgs, 'input'>>;
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType>;
createReaction?: Resolver<ResolversTypes['CreateReactionResult'], ParentType, ContextType, RequireFields<MutationCreateReactionArgs, 'input'>>;
createReminder?: Resolver<ResolversTypes['CreateReminderResult'], ParentType, ContextType, RequireFields<MutationCreateReminderArgs, 'input'>>;
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
deleteFilter?: Resolver<ResolversTypes['DeleteFilterResult'], ParentType, ContextType, RequireFields<MutationDeleteFilterArgs, 'id'>>;
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
deleteHighlightReply?: Resolver<ResolversTypes['DeleteHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightReplyArgs, 'highlightReplyId'>>;
deleteIntegration?: Resolver<ResolversTypes['DeleteIntegrationResult'], ParentType, ContextType, RequireFields<MutationDeleteIntegrationArgs, 'id'>>;
deleteLabel?: Resolver<ResolversTypes['DeleteLabelResult'], ParentType, ContextType, RequireFields<MutationDeleteLabelArgs, 'id'>>;
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
deleteReminder?: Resolver<ResolversTypes['DeleteReminderResult'], ParentType, ContextType, RequireFields<MutationDeleteReminderArgs, 'id'>>;
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
@ -5275,14 +5145,11 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFavoriteArticle?: Resolver<ResolversTypes['SetFavoriteArticleResult'], ParentType, ContextType, RequireFields<MutationSetFavoriteArticleArgs, 'id'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setIntegration?: Resolver<ResolversTypes['SetIntegrationResult'], ParentType, ContextType, RequireFields<MutationSetIntegrationArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
setLabelsForHighlight?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsForHighlightArgs, 'input'>>;
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
setRule?: Resolver<ResolversTypes['SetRuleResult'], ParentType, ContextType, RequireFields<MutationSetRuleArgs, 'input'>>;
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
@ -5290,12 +5157,8 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
updateEmail?: Resolver<ResolversTypes['UpdateEmailResult'], ParentType, ContextType, RequireFields<MutationUpdateEmailArgs, 'input'>>;
updateFilter?: Resolver<ResolversTypes['UpdateFilterResult'], ParentType, ContextType, RequireFields<MutationUpdateFilterArgs, 'input'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
updateLabel?: Resolver<ResolversTypes['UpdateLabelResult'], ParentType, ContextType, RequireFields<MutationUpdateLabelArgs, 'input'>>;
updateLinkShareInfo?: Resolver<ResolversTypes['UpdateLinkShareInfoResult'], ParentType, ContextType, RequireFields<MutationUpdateLinkShareInfoArgs, 'input'>>;
updatePage?: Resolver<ResolversTypes['UpdatePageResult'], ParentType, ContextType, RequireFields<MutationUpdatePageArgs, 'input'>>;
updateReminder?: Resolver<ResolversTypes['UpdateReminderResult'], ParentType, ContextType, RequireFields<MutationUpdateReminderArgs, 'input'>>;
updateSharedComment?: Resolver<ResolversTypes['UpdateSharedCommentResult'], ParentType, ContextType, RequireFields<MutationUpdateSharedCommentArgs, 'input'>>;
updateSubscription?: Resolver<ResolversTypes['UpdateSubscriptionResult'], ParentType, ContextType, RequireFields<MutationUpdateSubscriptionArgs, 'input'>>;
updateUser?: Resolver<ResolversTypes['UpdateUserResult'], ParentType, ContextType, RequireFields<MutationUpdateUserArgs, 'input'>>;
updateUserProfile?: Resolver<ResolversTypes['UpdateUserProfileResult'], ParentType, ContextType, RequireFields<MutationUpdateUserProfileArgs, 'input'>>;
@ -5380,12 +5243,8 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
apiKeys?: Resolver<ResolversTypes['ApiKeysResult'], ParentType, ContextType>;
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, Partial<QueryArticleSavingRequestArgs>>;
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
deviceTokens?: Resolver<ResolversTypes['DeviceTokensResult'], ParentType, ContextType>;
feedArticles?: Resolver<ResolversTypes['FeedArticlesResult'], ParentType, ContextType, Partial<QueryFeedArticlesArgs>>;
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
getFollowers?: Resolver<ResolversTypes['GetFollowersResult'], ParentType, ContextType, Partial<QueryGetFollowersArgs>>;
getFollowing?: Resolver<ResolversTypes['GetFollowingResult'], ParentType, ContextType, Partial<QueryGetFollowingArgs>>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -5395,11 +5254,9 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
recentEmails?: Resolver<ResolversTypes['RecentEmailsResult'], ParentType, ContextType>;
recentSearches?: Resolver<ResolversTypes['RecentSearchesResult'], ParentType, ContextType>;
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
rules?: Resolver<ResolversTypes['RulesResult'], ParentType, ContextType, Partial<QueryRulesArgs>>;
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
sendInstallInstructions?: Resolver<ResolversTypes['SendInstallInstructionsResult'], ParentType, ContextType>;
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType, Partial<QuerySubscriptionsArgs>>;
typeaheadSearch?: Resolver<ResolversTypes['TypeaheadSearchResult'], ParentType, ContextType, RequireFields<QueryTypeaheadSearchArgs, 'query'>>;
updatesSince?: Resolver<ResolversTypes['UpdatesSinceResult'], ParentType, ContextType, RequireFields<QueryUpdatesSinceArgs, 'since'>>;
@ -5767,7 +5624,7 @@ export type SetFavoriteArticleResultResolvers<ContextType = ResolverContext, Par
};
export type SetFavoriteArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFavoriteArticleSuccess'] = ResolversParentTypes['SetFavoriteArticleSuccess']> = {
favoriteArticle?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};

View file

@ -1107,20 +1107,14 @@ type Mutation {
createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult!
createGroup(input: CreateGroupInput!): CreateGroupResult!
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
createHighlightReply(input: CreateHighlightReplyInput!): CreateHighlightReplyResult!
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail: CreateNewsletterEmailResult!
createReaction(input: CreateReactionInput!): CreateReactionResult!
createReminder(input: CreateReminderInput!): CreateReminderResult!
deleteAccount(userID: ID!): DeleteAccountResult!
deleteFilter(id: ID!): DeleteFilterResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult!
deleteIntegration(id: ID!): DeleteIntegrationResult!
deleteLabel(id: ID!): DeleteLabelResult!
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteReaction(id: ID!): DeleteReactionResult!
deleteReminder(id: ID!): DeleteReminderResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
@ -1147,14 +1141,11 @@ type Mutation {
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
setFavoriteArticle(id: ID!): SetFavoriteArticleResult!
setFollow(input: SetFollowInput!): SetFollowResult!
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
setRule(input: SetRuleInput!): SetRuleResult!
setShareArticle(input: SetShareArticleInput!): SetShareArticleResult!
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult!
setWebhook(input: SetWebhookInput!): SetWebhookResult!
subscribe(input: SubscribeInput!): SubscribeResult!
@ -1162,12 +1153,8 @@ type Mutation {
updateEmail(input: UpdateEmailInput!): UpdateEmailResult!
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
updateLinkShareInfo(input: UpdateLinkShareInfoInput!): UpdateLinkShareInfoResult!
updatePage(input: UpdatePageInput!): UpdatePageResult!
updateReminder(input: UpdateReminderInput!): UpdateReminderResult!
updateSharedComment(input: UpdateSharedCommentInput!): UpdateSharedCommentResult!
updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult!
updateUser(input: UpdateUserInput!): UpdateUserResult!
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult!
@ -1297,12 +1284,8 @@ type Query {
apiKeys: ApiKeysResult!
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult!
deviceTokens: DeviceTokensResult!
feedArticles(after: String, first: Int, sharedByUser: ID, sort: SortParams): FeedArticlesResult!
filters: FiltersResult!
getFollowers(userId: ID): GetFollowersResult!
getFollowing(userId: ID): GetFollowingResult!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
hello: String
@ -1312,11 +1295,9 @@ type Query {
newsletterEmails: NewsletterEmailsResult!
recentEmails: RecentEmailsResult!
recentSearches: RecentSearchesResult!
reminder(linkId: ID!): ReminderResult!
rules(enabled: Boolean): RulesResult!
search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult!
sendInstallInstructions: SendInstallInstructionsResult!
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult!
typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult!
updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult!
@ -1814,7 +1795,7 @@ enum SetFavoriteArticleErrorCode {
union SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArticleSuccess
type SetFavoriteArticleSuccess {
favoriteArticle: Article!
success: Boolean!
}
type SetFollowError {

View file

@ -56,6 +56,7 @@ import {
UpdatesSinceError,
UpdatesSinceSuccess,
} from '../../generated/graphql'
import { getInternalLabelWithColor } from '../../repository/label'
import { libraryItemRepository } from '../../repository/library_item'
import { userRepository } from '../../repository/user'
import { createPageSaveRequest } from '../../services/create_page_save_request'
@ -825,7 +826,7 @@ export const bulkActionResolver = authorized<
return { errorCodes: [BulkActionErrorCode.BadRequest] }
}
labels = await findLabelsByIds(labelIds, uid)
labels = await findLabelsByIds(labelIds)
}
// parse query
@ -848,7 +849,7 @@ export const setFavoriteArticleResolver = authorized<
SetFavoriteArticleSuccessPartial,
SetFavoriteArticleError,
MutationSetFavoriteArticleArgs
>(async (_, { id }, { uid, log, pubsub }) => {
>(async (_, { id }, { uid, log }) => {
try {
analytics.track({
userId: uid,
@ -859,24 +860,20 @@ export const setFavoriteArticleResolver = authorized<
},
})
const label = {
id: '',
name: 'Favorites',
color: '#FFD700', // gold
const label = getInternalLabelWithColor('Favorites')
if (!label) {
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
}
const labels = await getLabelsAndCreateIfNotExist([label], uid)
// adds Favorites label to page
const updatedLibraryItem = await addLabelsToLibraryItem([label], id, uid)
await addLabelsToLibraryItem(labels, id, uid)
return {
favoriteArticle: {
...page,
labels: page.labels ? [...page.labels, label] : [label],
isArchived: !!page.archivedAt,
},
success: true,
}
} catch (error) {
log.info('Error adding Favorites label:', error)
log.info('Error adding Favorites label', error)
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
}
})

View file

@ -1,6 +1,5 @@
import { Between } from 'typeorm'
import { Filter } from '../../entity/filter'
import { User } from '../../entity/user'
import { env } from '../../env'
import {
DeleteFilterError,
@ -32,7 +31,7 @@ export const saveFilterResolver = authorized<
SaveFilterSuccess,
SaveFilterError,
MutationSaveFilterArgs
>(async (_, { input }, { authTrx, uid, log }) => {
>(async (_, { input }, { authTrx, log }) => {
try {
const filter = await authTrx(async (t) => {
return t.withRepository(filterRepository).save({
@ -89,40 +88,20 @@ export const deleteFilterResolver = authorized<
})
export const filtersResolver = authorized<FiltersSuccess, FiltersError>(
async (_, __, { claims, log }) => {
log.info('Getting filters', {
labels: {
source: 'resolver',
resolver: 'filtersResolver',
uid: claims.uid,
},
})
async (_, __, { authTrx, uid, log }) => {
try {
const user = await getRepository(User).findOneBy({ id: claims.uid })
if (!user) {
return {
errorCodes: [FiltersErrorCode.Unauthorized],
}
}
const filters = await getRepository(Filter).find({
where: { user: { id: claims.uid } },
order: { position: 'ASC' },
})
const filters = await authTrx((t) =>
t.getRepository(Filter).find({
where: { user: { id: uid } },
order: { position: 'ASC' },
})
)
return {
filters,
}
} catch (error) {
log.error('Error getting filters', {
error,
labels: {
source: 'resolver',
resolver: 'filtersResolver',
uid: claims.uid,
},
})
log.error('Error getting filters', error)
return {
errorCodes: [FiltersErrorCode.BadRequest],
@ -242,7 +221,7 @@ export const moveFilterResolver = authorized<
MoveFilterSuccess,
MoveFilterError,
MutationMoveFilterArgs
>(async (_, { input }, { claims: { uid }, log }) => {
>(async (_, { input }, { authTrx, uid, log }) => {
log.info('Moving filters', {
input,
filters: {
@ -255,17 +234,12 @@ export const moveFilterResolver = authorized<
const { filterId, afterFilterId } = input
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [MoveFilterErrorCode.Unauthorized],
}
}
const filter = await getRepository(Filter).findOne({
where: { id: filterId },
relations: ['user'],
})
const filter = await authTrx((t) =>
t.getRepository(Filter).findOne({
where: { id: filterId },
relations: ['user'],
})
)
if (!filter) {
return {
errorCodes: [MoveFilterErrorCode.NotFound],
@ -285,10 +259,12 @@ export const moveFilterResolver = authorized<
// if afterFilterId is not provided, move to the top
let newPosition = 0
if (afterFilterId) {
const afterFilter = await getRepository(Filter).findOne({
where: { id: afterFilterId },
relations: ['user'],
})
const afterFilter = await authTrx((t) =>
t.getRepository(Filter).findOne({
where: { id: afterFilterId },
relations: ['user'],
})
)
if (!afterFilter) {
return {
errorCodes: [MoveFilterErrorCode.NotFound],

View file

@ -54,56 +54,48 @@ export const subscriptionsResolver = authorized<
SubscriptionsSuccessPartial,
SubscriptionsError,
QuerySubscriptionsArgs
>(async (_obj, { sort, type }, { claims: { uid }, log }) => {
log.info('subscriptionsResolver')
>(
async (
_obj,
{ sort, type = SubscriptionType.Newsletter }, // default to newsletter
{ authTrx, uid, log }
) => {
try {
const sortBy =
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
analytics.track({
userId: uid,
event: 'subscriptions',
properties: {
env: env.server.apiEnv,
},
})
try {
const sortBy =
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SubscriptionsErrorCode.Unauthorized],
}
}
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({
user: { id: uid },
})
if (type && type == SubscriptionType.Newsletter) {
queryBuilder.andWhere({
type,
status: SubscriptionStatus.Active,
})
} else if (type && type == SubscriptionType.Rss) {
queryBuilder.andWhere({
type,
})
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
}).orWhere({
type: SubscriptionType.Rss,
const queryBuilder = await authTrx((t) =>
t
.getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({
user: { id: uid },
})
})
)
}
if (type && type == SubscriptionType.Newsletter) {
queryBuilder.andWhere({
type,
status: SubscriptionStatus.Active,
})
} else if (type && type == SubscriptionType.Rss) {
queryBuilder.andWhere({
type,
})
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
}).orWhere({
type: SubscriptionType.Rss,
})
})
)
}
const subscriptions = await queryBuilder
.orderBy('subscription.' + sortBy, sortOrder)
@ -132,13 +124,6 @@ export const unsubscribeResolver = authorized<
log.info('unsubscribeResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UnsubscribeErrorCode.Unauthorized],
}
}
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
@ -325,7 +310,7 @@ export const updateSubscriptionResolver = authorized<
UpdateSubscriptionSuccessPartial,
UpdateSubscriptionError,
MutationUpdateSubscriptionArgs
>(async (_, { input }, { claims: { uid }, log }) => {
>(async (_, { input }, { authTrx, uid, log }) => {
log.info('updateSubscriptionResolver')
try {
@ -338,34 +323,26 @@ export const updateSubscriptionResolver = authorized<
},
})
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UpdateSubscriptionErrorCode.Unauthorized],
const updatedSubscription = await authTrx(async (t) => {
// find existing subscription
const subscription = await t.getRepository(Subscription).findOneBy({
id: input.id,
user: { id: uid },
})
if (!subscription) {
throw new Error('subscription not found')
}
}
// find existing subscription
const subscription = await getRepository(Subscription).findOneBy({
id: input.id,
user: { id: uid },
})
if (!subscription) {
log.info('subscription not found')
return {
errorCodes: [UpdateSubscriptionErrorCode.NotFound],
}
}
// update subscription
const updatedSubscription = await getRepository(Subscription).save({
id: input.id,
name: input.name || undefined,
description: input.description || undefined,
lastFetchedAt: input.lastFetchedAt
? new Date(input.lastFetchedAt)
: undefined,
status: input.status || undefined,
// update subscription
return t.getRepository(Subscription).save({
id: input.id,
name: input.name || undefined,
description: input.description || undefined,
lastFetchedAt: input.lastFetchedAt
? new Date(input.lastFetchedAt)
: undefined,
status: input.status || undefined,
})
})
return {

View file

@ -2523,7 +2523,7 @@ const schema = gql`
| SetFavoriteArticleError
type SetFavoriteArticleSuccess {
favoriteArticle: Article!
success: Boolean!
}
type SetFavoriteArticleError {

View file

@ -1,63 +0,0 @@
import 'mocha'
import { expect } from 'chai'
import {
Highlight,
HighlightType,
Page,
PageContext,
} from '../../src/elastic/types'
import { createPubSubClient } from '../../src/pubsub'
import { deletePage } from '../../src/elastic/pages'
import {
addHighlightToPage,
searchHighlights,
} from '../../src/elastic/highlights'
import { createTestElasticPage } from '../util'
describe('highlights in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
refresh: true,
uid: userId,
}
describe('searchHighlights', () => {
const highlightId = 'highlightId'
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
const highlightData: Highlight = {
patch: 'test patch',
quote: 'test content',
shortId: 'test shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
type: HighlightType.Highlight,
}
await addHighlightToPage(page.id, highlightData, ctx)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('searches highlights', async () => {
const [searchResults, count] = (await searchHighlights(
{
query: 'test',
},
page.userId
)) || [[], 0]
expect(count).to.eq(1)
expect(searchResults[0].id).to.eq(highlightId)
})
})
})

View file

@ -1,143 +0,0 @@
import 'mocha'
import { expect } from 'chai'
import {
ArticleSavingRequestStatus,
Highlight,
HighlightType,
Label,
Page,
PageContext,
PageType,
} from '../../src/elastic/types'
import { createPubSubClient } from '../../src/pubsub'
import { createPage, deletePage, getPageById } from '../../src/elastic/pages'
import { addLabelInPage, setLabelsForHighlight } from '../../src/elastic/labels'
import { addHighlightToPage } from '../../src/elastic/highlights'
describe('labels in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
refresh: true,
uid: userId,
}
describe('addLabelInPage', () => {
let page: Page
let label: Label = {
id: 'Test label id',
name: 'test label',
color: '#07D2D1',
}
let newLabel: Label
before(async () => {
// create a testing page
page = {
id: '',
hash: 'test hash',
userId: userId,
pageType: PageType.Article,
title: 'test title',
content: '<p>test</p>',
slug: 'test slug',
createdAt: new Date(),
updatedAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/getting-started-with-omnivore',
archivedAt: new Date(),
labels: [label],
state: ArticleSavingRequestStatus.Succeeded,
}
page.id = (await createPage(page, ctx))!
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
context('when the label not exist in the page', () => {
before(() => {
newLabel = {
id: 'new label id',
name: 'new label',
color: '#07D2D1',
}
})
it('adds the label to the page', async () => {
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.true
const updatedPage = await getPageById(page.id)
expect(updatedPage?.labels).to.deep.include(label)
})
})
context('when the label exists in the page', () => {
before(() => {
newLabel = label
})
it('does not add the label to the page', async () => {
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.false
})
})
})
describe('setLabelsForHighlight', () => {
const page = {
id: 'testPageId',
hash: 'test set labels for highlight hash',
userId: userId,
pageType: PageType.Article,
title: 'test set labels for highlight title',
content: '<p>test</p>',
slug: 'test set labels for highlight slug',
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/setting-labels-for-highlight',
state: ArticleSavingRequestStatus.Succeeded,
}
const highlightId = 'highlightId'
const label: Label = {
id: 'test label id',
name: 'test label',
color: '#07D2D1',
}
before(async () => {
// create a testing page
await createPage(page, ctx)
const highlightData: Highlight = {
patch: 'test set labels patch',
quote: 'test set labels quote',
shortId: 'test set labels shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
type: HighlightType.Highlight,
}
await addHighlightToPage(page.id, highlightData, ctx)
})
after(async () => {
await deletePage(page.id, ctx)
})
it('sets labels for highlights', async () => {
const result = await setLabelsForHighlight(highlightId, [label], ctx)
expect(result).to.be.true
})
})
})

View file

@ -1,264 +0,0 @@
import { expect } from 'chai'
import 'mocha'
import {
countByCreatedAt,
createPage,
deletePage,
deletePagesByParam,
getPageById,
getPageByParam,
searchAsYouType,
searchLibraryItems,
updatePage,
} from '../../src/elastic/pages'
import {
ArticleSavingRequestStatus,
Page,
PageContext,
PageType,
} from '../../src/elastic/types'
import { createPubSubClient } from '../../src/pubsub'
import { InFilter, ReadFilter } from '../../src/utils/search'
import { createTestElasticPage } from '../util'
describe('pages in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
refresh: true,
uid: userId,
}
describe('createPage', () => {
let newPageId: string
after(async () => {
await deletePage(newPageId, ctx)
})
it('creates a page', async () => {
const newPageData: Page = {
id: '',
hash: 'hash',
userId: 'userId',
pageType: PageType.Article,
title: 'test',
content: 'test',
slug: 'test',
createdAt: new Date(),
updatedAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/testUrl',
state: ArticleSavingRequestStatus.Succeeded,
}
newPageId = (await createPage(newPageData, ctx))!
expect(newPageId).to.be.a('string')
})
})
describe('getPageByParam', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('gets a page by url', async () => {
const pageFound = await getPageByParam({
userId: page.userId,
url: page.url,
})
expect(pageFound).not.undefined
})
})
describe('getPageById', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('gets a page by id', async () => {
const pageFound = await getPageById(page.id)
expect(pageFound).not.undefined
})
})
describe('updatePage', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('updates a page', async () => {
const newTitle = 'new title'
const updatedPageData: Partial<Page> = {
title: newTitle,
}
await updatePage(page.id, updatedPageData, ctx)
const updatedPage = await getPageById(page.id)
expect(updatedPage?.title).to.eql(newTitle)
})
})
describe('searchLibraryItems', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('searches pages', async () => {
const searchResults = await searchLibraryItems(
{
dateFilters: [],
hasFilters: [],
inFilter: InFilter.ALL,
labelFilters: [],
readFilter: ReadFilter.ALL,
query: page.content,
},
page.userId
)
expect(searchResults).not.undefined
})
})
describe('countByCreatedAt', () => {
const createdAt = Date.now() - 60 * 60 * 24 * 1000
let page: Page
before(async () => {
page = {
id: '',
hash: 'hash',
userId: userId,
pageType: PageType.Article,
title: 'test',
content: 'test',
slug: 'test',
createdAt: new Date(createdAt),
savedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/testCount',
state: ArticleSavingRequestStatus.Succeeded,
}
page.id = (await createPage(page, ctx))!
})
after(async () => {
await deletePage(page.id, ctx)
})
it('counts pages by createdAt', async () => {
const count = await countByCreatedAt(userId, createdAt, createdAt)
expect(count).to.eq(1)
})
})
describe('deletePagesByParam', () => {
const userId = 'test user id'
before(async () => {
// create a testing page
await createPage(
{
content: 'deletePagesByParam content',
createdAt: new Date(),
hash: '',
id: '',
pageType: PageType.Article,
readingProgressAnchorIndex: 0,
readingProgressPercent: 0,
savedAt: new Date(),
slug: 'deletePagesByParam slug',
state: ArticleSavingRequestStatus.Succeeded,
title: 'deletePagesByParam title',
url: 'https://localhost/deletePagesByParam',
userId,
},
ctx
)
})
it('deletes page by userId', async () => {
const deleted = await deletePagesByParam(
{
userId,
},
ctx
)
expect(deleted).to.be.true
})
})
describe('searchAsYouType', () => {
let pageId: string
before(async () => {
// create a testing page
pageId = (await createPage(
{
content: '',
createdAt: new Date(),
hash: '',
id: '',
pageType: PageType.Article,
readingProgressAnchorIndex: 0,
readingProgressPercent: 0,
savedAt: new Date(),
slug: '',
state: ArticleSavingRequestStatus.Succeeded,
title: 'search as you type',
url: '',
userId,
},
ctx
))!
})
after(async () => {
// delete the testing page
await deletePage(pageId, ctx)
})
it('searches pages', async () => {
const searchResults = await searchAsYouType(userId, 'search')
expect(searchResults).not.empty
})
})
})