diff --git a/src/server/src/api/v1/controllers/account/AccountController.ts b/src/server/src/api/v1/controllers/account/AccountController.ts index 3bced5b8..5c4a5ccc 100644 --- a/src/server/src/api/v1/controllers/account/AccountController.ts +++ b/src/server/src/api/v1/controllers/account/AccountController.ts @@ -294,7 +294,7 @@ export class AccountController extends BaseControllerWithPersistentStorageAccess if (!token.user) { throw new AuthError('UNKNOWN_ALIAS'); } - await token.user.syncProfile(); // get profile + await token.user.syncProfile(req); // get profile return { profile: token.user.toJSON().profile ?? {}, token: token.wrapped, @@ -314,7 +314,7 @@ export class AccountController extends BaseControllerWithPersistentStorageAccess if (!user) { throw new AuthError('UNKNOWN_ALIAS'); } - await user.syncProfile(); // get profile + await user.syncProfile(req); // get profile if (token) { // account is anonymous, return JWT return { @@ -376,7 +376,7 @@ export class AccountController extends BaseControllerWithPersistentStorageAccess } } // user is authenticated, generate JWT else { - await user.syncProfile(); // get profile + await user.syncProfile(req); // get profile const userData = user.toJSON(); const token = await JWT.as(body.requestedRole ?? 'standard', userData.id); await user.createCredential('jwt', token); @@ -414,7 +414,7 @@ export class AccountController extends BaseControllerWithPersistentStorageAccess @Body() body: RegisterAliasRequest ): Promise { const user = await User.from(body, req.body); - await user.syncProfile(); + await user.syncProfile(req); if (user.toJSON().profile?.linkedThirdPartyAccounts?.includes(body.otherAlias.thirdParty.name)) { throw new AuthError('DUPLICATE_USER'); } diff --git a/src/server/src/api/v1/controllers/category/CategoryController.ts b/src/server/src/api/v1/controllers/category/CategoryController.ts index 03c4ea20..79a4fd35 100644 --- a/src/server/src/api/v1/controllers/category/CategoryController.ts +++ b/src/server/src/api/v1/controllers/category/CategoryController.ts @@ -13,11 +13,7 @@ import { Tags, } from 'tsoa'; -import { - BaseController, - BulkResponse, - InteractionRequest, -} from '../'; +import { BaseController, BulkResponse } from '../'; import { SupportedLocale } from '../../../../core/locales'; import { AuthError, @@ -27,6 +23,7 @@ import { import { Category, CategoryInteraction, + InteractionCreationAttributes, InteractionType, PublicCategoryAttributes, } from '../../schema'; @@ -58,14 +55,15 @@ export class CategoryController extends BaseController { @Request() req: ExpressRequest, @Path() targetId: number, @Path() type: InteractionType, - @Body() body: InteractionRequest + @Body() body: InteractionCreationAttributes, ): Promise { const user = req.jwt?.user; - const { - content, metadata, remoteAddr, - } = body; const interaction = await CategoryInteraction.create({ - content, metadata, remoteAddr, targetId, type, userId: user?.id, + ...body, + remoteAddr: req.ip, + targetId, + type, + userId: user?.id, }); if (!interaction) { throw new InternalError('Failed to create interaction'); diff --git a/src/server/src/api/v1/controllers/event/EventController.ts b/src/server/src/api/v1/controllers/event/EventController.ts index ae983c81..bae0ec27 100644 --- a/src/server/src/api/v1/controllers/event/EventController.ts +++ b/src/server/src/api/v1/controllers/event/EventController.ts @@ -12,7 +12,7 @@ import { Tags, } from 'tsoa'; -import { BulkResponse, InteractionRequest } from '../'; +import { BulkResponse } from '../'; import { AuthError, Request as ExpressRequest, @@ -21,6 +21,7 @@ import { import { Event, EventInteraction, + InteractionCreationAttributes, InteractionType, PublicEventAttributes, } from '../../schema'; @@ -50,14 +51,15 @@ export class EventController { @Request() req: ExpressRequest, @Path() targetId: number, @Path() type: InteractionType, - @Body() body: InteractionRequest + @Body() body: InteractionCreationAttributes ): Promise { const user = req.jwt?.user; - const { - content, metadata, remoteAddr, - } = body; const interaction = await EventInteraction.create({ - content, metadata, remoteAddr, targetId, type, userId: user?.id, + ...body, + remoteAddr: req.ip, + targetId, + type, + userId: user?.id, }); if (!interaction) { throw new InternalError('Failed to create interaction'); diff --git a/src/server/src/api/v1/controllers/profile/ProfileController.ts b/src/server/src/api/v1/controllers/profile/ProfileController.ts index fe73796a..aa43eab0 100644 --- a/src/server/src/api/v1/controllers/profile/ProfileController.ts +++ b/src/server/src/api/v1/controllers/profile/ProfileController.ts @@ -33,7 +33,7 @@ export class ProfileController extends BaseControllerWithPersistentStorageAccess @Request() req: ExpressRequest ): Promise { const user = req.jwt.user; - await user.syncProfile(); + await user.syncProfile(req); const userData = user.toJSON(); return { profile: userData.profile }; } diff --git a/src/server/src/api/v1/controllers/publisher/PublisherController.ts b/src/server/src/api/v1/controllers/publisher/PublisherController.ts index 760f897b..967533d4 100644 --- a/src/server/src/api/v1/controllers/publisher/PublisherController.ts +++ b/src/server/src/api/v1/controllers/publisher/PublisherController.ts @@ -12,11 +12,7 @@ import { Tags, } from 'tsoa'; -import { - BaseController, - BulkResponse, - InteractionRequest, -} from '..'; +import { BaseController, BulkResponse } from '..'; import { SupportedLocale } from '../../../../core/locales'; import { AuthError, @@ -24,6 +20,7 @@ import { InternalError, } from '../../middleware'; import { + InteractionCreationAttributes, InteractionType, PublicPublisherAttributes, Publisher, @@ -57,14 +54,15 @@ export class PublisherController extends BaseController { @Request() req: ExpressRequest, @Path() targetId: number, @Path() type: InteractionType, - @Body() body: InteractionRequest + @Body() body: InteractionCreationAttributes, ): Promise { const user = req.jwt?.user; - const { - content, metadata, remoteAddr, - } = body; const interaction = await PublisherInteraction.create({ - content, metadata, remoteAddr, targetId, type, userId: user?.id, + ...body, + remoteAddr: req.ip, + targetId, + type, + userId: user?.id, }); if (!interaction) { throw new InternalError('Failed to create interaction'); diff --git a/src/server/src/api/v1/controllers/summary/SummaryController.ts b/src/server/src/api/v1/controllers/summary/SummaryController.ts index 37f5be61..0f359f89 100644 --- a/src/server/src/api/v1/controllers/summary/SummaryController.ts +++ b/src/server/src/api/v1/controllers/summary/SummaryController.ts @@ -19,7 +19,6 @@ import { BulkMetadataResponse, BulkResponse, DestroyResponse, - InteractionRequest, } from '../'; import { BaseControllerWithPersistentStorageAccess } from '../'; import { SupportedLocale } from '../../../../core/locales'; @@ -30,6 +29,7 @@ import { InternalError, } from '../../middleware'; import { + InteractionCreationAttributes, InteractionType, PublicRecapAttributes, PublicSummaryAttributes, @@ -132,14 +132,11 @@ export class SummaryController extends BaseControllerWithPersistentStorageAccess @Request() req: ExpressRequest, @Path() targetId: number, @Path() type: InteractionType, - @Body() body: InteractionRequest + @Body() body: InteractionCreationAttributes ): Promise { const user = req.jwt?.user; - console.log(user); - const { content, metadata } = body; const interaction = await SummaryInteraction.create({ - content, - metadata, + ...body, remoteAddr: req.ip, targetId, type, @@ -152,7 +149,7 @@ export class SummaryController extends BaseControllerWithPersistentStorageAccess await new MailService().sendMail({ from: 'hello@readless.ai', subject: 'Feedback', - text: [content, JSON.stringify(metadata)].join('\n\n'), + text: [body.content, JSON.stringify(body.metadata)].join('\n\n'), to: 'feedback@readless.ai', }); } diff --git a/src/server/src/api/v1/controllers/types.ts b/src/server/src/api/v1/controllers/types.ts index 3dcb62e7..40a312ac 100644 --- a/src/server/src/api/v1/controllers/types.ts +++ b/src/server/src/api/v1/controllers/types.ts @@ -50,30 +50,3 @@ export type LocalizeRequest = JobRequest & { export type TtsRequest = JobRequest & { voice?: string; }; - -// interactions - -export type InteractionRequest = { - userId?: number; - remoteAddr?: string; - content?: string; - metadata?: Record; -}; - -export type InteractionUserVote = 'down' | 'up'; - -// uh this type exists? forcing rebuild -export type InteractionResponse = { - bookmark: number; - userBookmarked?: boolean; - favorite: number; - userFavorited?: boolean; - comment: number; - downvote: number; - listen: number; - read: number; - share: number; - upvote: number; - uservote?: InteractionUserVote; - view: number; -}; diff --git a/src/server/src/api/v1/middleware/AuthMiddleware.ts b/src/server/src/api/v1/middleware/AuthMiddleware.ts index abf53270..d4406955 100644 --- a/src/server/src/api/v1/middleware/AuthMiddleware.ts +++ b/src/server/src/api/v1/middleware/AuthMiddleware.ts @@ -1,6 +1,5 @@ import { AuthError, internalErrorHandler } from './internal-errors'; import { RequestHandler } from './types'; -import { JWT } from '../controllers/types'; type AuthMiddlewareOptions = { scope?: string[]; diff --git a/src/server/src/api/v1/middleware/PreprocessHeadersMiddleware.ts b/src/server/src/api/v1/middleware/PreprocessHeadersMiddleware.ts index d14d20f3..a8ed5e82 100644 --- a/src/server/src/api/v1/middleware/PreprocessHeadersMiddleware.ts +++ b/src/server/src/api/v1/middleware/PreprocessHeadersMiddleware.ts @@ -6,7 +6,6 @@ export const preprocessHeadersMiddleware: RequestHandler = async (req, res, next try { delete req.body.userId; delete req.query.userId; - req.ip = req.get('x-forwarded-from') || req.ip; const auth = req.get('authorization') || ''; const [type, token] = auth.split(' '); if (type === 'Bearer') { @@ -14,6 +13,8 @@ export const preprocessHeadersMiddleware: RequestHandler = async (req, res, next req.jwt = jwt; req.body.userId = jwt.userId; } + const version = req.get('x-version'); + req.version = version; next(); } catch (e) { internalErrorHandler(res, e); diff --git a/src/server/src/api/v1/middleware/types.ts b/src/server/src/api/v1/middleware/types.ts index e4dfac02..add64f49 100644 --- a/src/server/src/api/v1/middleware/types.ts +++ b/src/server/src/api/v1/middleware/types.ts @@ -8,6 +8,7 @@ import { JWT } from '../controllers/types'; export type Request = ExpressRequest & { jwt?: JWT; + version?: string; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/server/src/api/v1/schema/resources/achievement/Achievement.model.ts b/src/server/src/api/v1/schema/resources/achievement/Achievement.model.ts index e4df4498..75d57a9b 100644 --- a/src/server/src/api/v1/schema/resources/achievement/Achievement.model.ts +++ b/src/server/src/api/v1/schema/resources/achievement/Achievement.model.ts @@ -159,7 +159,7 @@ export class Achievement< const data = await UserMetadata.findAll({ where: { key: 'bookmarkedSummaries' } }); - const users = data.filter((d) => (JSON.parse(d) as number[]).length >= 3).map((d) => d.userId); + const users = data.filter((d) => Object.values(d.value).length >= 3).map((d) => d.userId); return await User.findAll({ where : { id: users } }); } catch (e) { console.error(e); @@ -167,13 +167,13 @@ export class Achievement< }, getProgress: async (user) => { try { - const data = await UserMetadata.findAll({ + const data = await UserMetadata.findOne({ where: { key: 'bookmarkedSummaries', userId: user.id, } }); - const count = (JSON.parse(data?.value ?? '[]') as number[]).length; + const count = (Object.values(data?.value ?? [])).length; return count / 3; } catch (e) { return 0 @@ -190,7 +190,7 @@ export class Achievement< const data = await UserMetadata.findAll({ where: { key: 'bookmarkedSummaries' } }); - const users = data.filter((d) => (JSON.parse(d) as number[]).length >= 10).map((d) => d.userId); + const users = data.filter((d) => Object.values(d.value).length >= 10).map((d) => d.userId); return await User.findAll({ where : { id: users } }); } catch (e) { console.error(e); @@ -198,13 +198,13 @@ export class Achievement< }, getProgress: async (user) => { try { - const data = await UserMetadata.findAll({ + const data = await UserMetadata.findOne({ where: { key: 'bookmarkedSummaries', userId: user.id, } }); - const count = (JSON.parse(data?.value ?? '[]') as number[]).length; + const count = (Object.values(data?.value ?? [])).length; return count / 10; } catch (e) { return 0 @@ -221,7 +221,7 @@ export class Achievement< const data = await UserMetadata.findAll({ where: { key: 'bookmarkedSummaries' } }); - const users = data.filter((d) => (JSON.parse(d) as number[]).length >= 30).map((d) => d.userId); + const users = data.filter((d) => Object.values(d.value).length >= 30).map((d) => d.userId); return await User.findAll({ where : { id: users } }); } catch (e) { console.error(e); @@ -229,13 +229,13 @@ export class Achievement< }, getProgress: async (user) => { try { - const data = await UserMetadata.findAll({ + const data = await UserMetadata.findOne({ where: { key: 'bookmarkedSummaries', userId: user.id, } }); - const count = (JSON.parse(data?.value ?? '[]') as number[]).length; + const count = Object.values(data?.value ?? []).length; return count / 30; } catch (e) { return 0 @@ -252,7 +252,7 @@ export class Achievement< const data = await UserMetadata.findAll({ where: { key: 'bookmarkedSummaries' } }); - const users = data.filter((d) => (JSON.parse(d) as number[]).length >= 100).map((d) => d.userId); + const users = data.filter((d) => Object.values(d.value).length >= 100).map((d) => d.userId); return await User.findAll({ where : { id: users } }); } catch (e) { console.error(e); @@ -260,13 +260,13 @@ export class Achievement< }, getProgress: async (user) => { try { - const data = await UserMetadata.findAll({ + const data = await UserMetadata.findOne({ where: { key: 'bookmarkedSummaries', userId: user.id, } }); - const count = (JSON.parse(data?.value ?? '[]') as number[]).length; + const count = Object.values(data?.value ?? []).length; return count / 100; } catch (e) { return 0 diff --git a/src/server/src/api/v1/schema/resources/interaction/Interaction.model.ts b/src/server/src/api/v1/schema/resources/interaction/Interaction.model.ts index b5b84b8a..c436546f 100644 --- a/src/server/src/api/v1/schema/resources/interaction/Interaction.model.ts +++ b/src/server/src/api/v1/schema/resources/interaction/Interaction.model.ts @@ -31,11 +31,17 @@ export abstract class Interaction< type: DataType.STRING, }) declare type: InteractionType; + + @Column({ + defaultValue: false, + type: DataType.BOOLEAN, + }) + declare revert: boolean; @Column({ type: DataType.TEXT }) declare content?: string; @Column({ type: DataType.JSON }) declare metadata?: Record; - + } diff --git a/src/server/src/api/v1/schema/resources/interaction/Interaction.types.ts b/src/server/src/api/v1/schema/resources/interaction/Interaction.types.ts index c66d1bee..07f916ba 100644 --- a/src/server/src/api/v1/schema/resources/interaction/Interaction.types.ts +++ b/src/server/src/api/v1/schema/resources/interaction/Interaction.types.ts @@ -1,30 +1,26 @@ import { DatedAttributes } from '../../types'; -export const INTERACTION_TYPES = { - bookmark: 'bookmark', - comment: 'comment', - copy: 'copy', - downvote: 'downvote', - favorite: 'favorite', - feedback: 'feedback', - follow: 'follow', - hide: 'hide', - listen: 'listen', - read: 'read', - search: 'search', - share: 'share', - translate: 'translate', - unfavorite: 'unfavorite', - unfollow: 'unfollow', - unhide: 'unhide', - untranslate: 'untranslate', - upvote: 'upvote', - view: 'view', - vote: 'vote', -} as const; - -export type InteractionType = typeof INTERACTION_TYPES[keyof typeof INTERACTION_TYPES]; +export const INTERACTION_TYPES = [ + 'bookmark', + 'comment', + 'copy', + 'downvote', + 'favorite', + 'feedback', + 'follow', + 'hide', + 'listen', + 'read', + 'search', + 'share', + 'translate', + 'upvote', + 'view', + 'vote', +]; +export type InteractionType = typeof INTERACTION_TYPES[number]; + export type InteractionAttributes = DatedAttributes & { /** user that made this interaction **/ userId?: number; @@ -34,6 +30,8 @@ export type InteractionAttributes = DatedAttributes & { targetId: number; /** type of this interaction */ type: InteractionType; + /** true if the interaction was a reversion */ + revert: boolean; /** value associated with the interaction */ content?: string; metadata?: Record; @@ -44,6 +42,7 @@ export type InteractionCreationAttributes = { remoteAddr?: string; targetId: number; type: InteractionType; + revert?: boolean; content?: string; metadata?: Record; }; diff --git a/src/server/src/api/v1/schema/user/User.model.ts b/src/server/src/api/v1/schema/user/User.model.ts index f639f81f..ea2b0115 100644 --- a/src/server/src/api/v1/schema/user/User.model.ts +++ b/src/server/src/api/v1/schema/user/User.model.ts @@ -10,7 +10,7 @@ import { MetricsRequest, MetricsResponse, } from '../../controllers/types'; -import { AuthError } from '../../middleware'; +import { AuthError, Request as ExpressRequest } from '../../middleware'; import { Achievement, Achievements, @@ -19,6 +19,7 @@ import { AliasPayload, AliasType, CalculateStreakOptions, + CategoryInteraction, Credential, CredentialCreationAttributes, CredentialType, @@ -28,6 +29,7 @@ import { InteractionType, MetadataType, Profile, + PublisherInteraction, QueryFactory, QueryOptions, RequestLog, @@ -370,7 +372,7 @@ export class User [type, []])) as { [key in InteractionType]: [] }, + ...Object.fromEntries(INTERACTION_TYPES.map((type) => [type, []])) as { [key in InteractionType]: [] }, read: readCounts, share: shareCounts, }, @@ -378,7 +380,7 @@ export class User s.userId === user?.id)?.rank ?? Number.MAX_SAFE_INTEGER, interactionCounts: { - ...Object.fromEntries(Object.keys(INTERACTION_TYPES).map((type) => [type, 0])) as { [key in InteractionType]: number }, + ...Object.fromEntries(INTERACTION_TYPES.map((type) => [type, 0])) as { [key in InteractionType]: number }, read: readCounts.find((s) => s.userId === user?.id)?.rank ?? Number.MAX_SAFE_INTEGER, share: shareCounts.find((s) => s.userId === user?.id)?.rank ?? Number.MAX_SAFE_INTEGER, }, @@ -450,7 +452,7 @@ export class User { + public async syncProfile(req?: ExpressRequest): Promise { const profile: Profile = {}; const aliases = await this.findAliases('email'); const metadata = await UserMetadata.findAll({ where: { userId: this.id } }); @@ -461,6 +463,89 @@ export class User a.type.split('/')[1] as ThirdParty); profile.preferences = Object.fromEntries(metadata.filter((meta) => meta.type === 'pref').map((meta) => [meta.key, typeof meta.value === 'string' ? JSON.parse(meta.value) : meta.value])); + if ((req?.version ?? '') >= '1.17.11') { + profile.preferences.bookmarkedSummaries = Object.fromEntries((await SummaryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'bookmark', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.readSummaries = Object.fromEntries((await SummaryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'read', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.removedSummaries = Object.fromEntries((await SummaryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'hide', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.followedPublishers = Object.fromEntries((await PublisherInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'follow', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.favoritedPublishers = Object.fromEntries((await PublisherInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'favorite', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.excludedPublishers = Object.fromEntries((await PublisherInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'hide', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.followedCategories = Object.fromEntries((await CategoryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'follow', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.favoritedCategories = Object.fromEntries((await CategoryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'favortie', + userId: this.id, + }, + })).map((i) => [i.id, true])); + profile.preferences.excludedCategories = Object.fromEntries((await CategoryInteraction.findAll({ + group: ['id'], + order: [['createdAt', 'desc']], + where: { + revert: false, + type: 'hide', + userId: this.id, + }, + })).map((i) => [i.id, true])); + } profile.createdAt = this.createdAt; const stats = await this.getStats(); profile.updatedAt = new Date(Math.max(updatedAt.valueOf(), stats.updatedAt.valueOf()));