diff --git a/packages/api/src/entity/library_item.ts b/packages/api/src/entity/library_item.ts index fb92fa618..f6a2f83de 100644 --- a/packages/api/src/entity/library_item.ts +++ b/packages/api/src/entity/library_item.ts @@ -204,4 +204,7 @@ export class LibraryItem { @Column('text') highlightAnnotations?: string[] + + @Column('timestamptz') + seenAt?: Date } diff --git a/packages/api/src/entity/public_item.ts b/packages/api/src/entity/public_item.ts new file mode 100644 index 000000000..517c9fab6 --- /dev/null +++ b/packages/api/src/entity/public_item.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { PublicItemInteraction } from './public_item_interaction' + +@Entity() +export class PublicItem { + @PrimaryGeneratedColumn('uuid') + id!: string + + @OneToOne(() => PublicItemInteraction) + interaction?: PublicItemInteraction + + @Column('uuid') + sourceId!: string + + @Column('text') + type!: string + + @Column('text') + title!: string + + @Column('text') + url!: string + + @Column('boolean') + approved!: boolean + + @Column('text', { nullable: true }) + thumbnail?: string | null + + @Column('text', { nullable: true }) + previewContent?: string | null + + @Column('text', { nullable: true }) + languageCode?: string | null + + @Column('text', { nullable: true }) + author?: string | null + + @Column('text', { nullable: true }) + dir?: string | null + + @Column('timestamptz', { nullable: true }) + publishedAt?: Date | null + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/packages/api/src/entity/public_item_interaction.ts b/packages/api/src/entity/public_item_interaction.ts new file mode 100644 index 000000000..379962d4d --- /dev/null +++ b/packages/api/src/entity/public_item_interaction.ts @@ -0,0 +1,42 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { PublicItem } from './public_item' +import { User } from './user' + +@Entity({ name: 'public_item_interactions' }) +export class PublicItemInteraction { + @PrimaryGeneratedColumn('uuid') + id!: string + + @OneToOne(() => PublicItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'public_item_id' }) + publicItem!: PublicItem + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('timestamptz') + seenAt?: Date + + @Column('timestamptz') + savedAt?: Date + + @Column('timestamptz') + likedAt?: Date + + @Column('timestamptz') + broadcastedAt?: Date + + @Column('timestamptz') + createdAt!: Date + + @Column('timestamptz') + updated!: Date +} diff --git a/packages/api/src/jobs/update_just_read_feed.ts b/packages/api/src/jobs/update_just_read_feed.ts new file mode 100644 index 000000000..7e22f007a --- /dev/null +++ b/packages/api/src/jobs/update_just_read_feed.ts @@ -0,0 +1,40 @@ +import { searchLibraryItems } from '../services/library_item' +import { findUnseenPublicItems } from '../services/public_item' +import { logger } from '../utils/logger' + +interface JustReadFeedUpdateData { + userId: string +} + +const selectCandidates = async (userId: string) => { + // get last 100 library items saved and not seen by user + const privateCandidates = await searchLibraryItems( + { + size: 100, + includeContent: false, + query: `-is:seen`, + }, + userId + ) + + // get candidates from public inventory + const publicCandidates = await findUnseenPublicItems(userId, { + limit: 100, + }) + + // TODO: mix candidates + return privateCandidates.concat(publicCandidates) +} + +const updateJustReadFeed = async (data: JustReadFeedUpdateData) => { + const { userId } = data + logger.info(`Updating just read feed for user ${userId}`) + + const candidates = await selectCandidates(userId) + logger.info(`Found ${candidates.length} candidates`) + + // TODO: integrity check on candidates? + // TODO: rank candidates + + // TODO: prepend candidates to feed in redis +} diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index b9e3aeb3e..03a66e97c 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -60,6 +60,7 @@ enum ReadFilter { READ = 'read', READING = 'reading', UNREAD = 'unread', + SEEN = 'seen', } enum InFilter { @@ -332,6 +333,8 @@ export const buildQueryString = ( return 'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98' case ReadFilter.UNREAD: return 'library_item.reading_progress_bottom_percent < 2' + case ReadFilter.SEEN: + return 'library_item.seen_at IS NOT NULL' default: throw new Error(`Unexpected keyword: ${value}`) } diff --git a/packages/api/src/services/public_item.ts b/packages/api/src/services/public_item.ts new file mode 100644 index 000000000..58243abda --- /dev/null +++ b/packages/api/src/services/public_item.ts @@ -0,0 +1,28 @@ +import { IsNull } from 'typeorm' +import { PublicItem } from '../entity/public_item' +import { getRepository } from '../repository' + +export const findUnseenPublicItems = async ( + userId: string, + options: { + limit?: number + offset?: number + } +) => + getRepository(PublicItem).find({ + where: { + interaction: IsNull(), + interaction: { + user: { + id: userId, + }, + seenAt: IsNull(), + }, + approved: true, + }, + order: { + createdAt: 'DESC', + }, + take: options.limit, + skip: options.offset, + }) diff --git a/packages/db/migrations/0177.do.public_item.sql b/packages/db/migrations/0177.do.public_item.sql index 4d0a1a750..c657dced7 100755 --- a/packages/db/migrations/0177.do.public_item.sql +++ b/packages/db/migrations/0177.do.public_item.sql @@ -16,6 +16,9 @@ CREATE TABLE omnivore.public_item_source ( updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TRIGGER update_public_item_source_modtime BEFORE UPDATE ON omnivore.public_item_source FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + + CREATE TABLE omnivore.public_item ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), source_id uuid NOT NULL, -- user_id or public_item_source_id @@ -33,18 +36,8 @@ CREATE TABLE omnivore.public_item ( updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE omnivore.public_item_features ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), - public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE, - classified_topic TEXT, - sentiment_score FLOAT, - writing_style TEXT, - popularity_score FLOAT, - embedding VECTOR(768), - created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP -); +CREATE TRIGGER update_public_item_modtime BEFORE UPDATE ON omnivore.public_item FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); -CREATE INDEX public_item_feature_public_item_id_idx ON omnivore.public_item_features(public_item_id); CREATE TABLE omnivore.public_item_stats ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), @@ -57,28 +50,26 @@ CREATE TABLE omnivore.public_item_stats ( ); CREATE INDEX public_item_stats_public_item_id_idx ON omnivore.public_item_stats(public_item_id); +CREATE TRIGGER update_public_item_stats_modtime BEFORE UPDATE ON omnivore.public_item_stats FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + CREATE TABLE omnivore.public_item_interactions ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), user_id uuid NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE, public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE, - action TEXT NOT NULL, -- save, like, broadcast, comment, see - action_data TEXT, -- for comment, the comment text + saved_at TIMESTAMPTZ, + liked_at TIMESTAMPTZ, + broadcasted_at TIMESTAMPTZ, + seen_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX public_item_interaction_user_id_idx ON omnivore.public_item_interactions(user_id); CREATE INDEX public_item_interaction_public_item_id_idx ON omnivore.public_item_interactions(public_item_id); +CREATE TRIGGER update_public_item_interactions_modtime BEFORE UPDATE ON omnivore.public_item_interactions FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); -CREATE TABLE omnivore.library_item_interactions ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), - user_id uuid NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE, - library_item_id uuid NOT NULL REFERENCES omnivore.library_item(id) ON DELETE CASCADE, - action TEXT NOT NULL, -- seen - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX library_item_interaction_user_id_idx ON omnivore.library_item_interactions(user_id); -CREATE INDEX library_item_interaction_library_item_id_idx ON omnivore.library_item_interactions(library_item_id); +ALTER TABLE omnivore.library_item ADD COLUMN seen_at timestamptz; COMMIT; diff --git a/packages/db/migrations/0177.undo.public_item.sql b/packages/db/migrations/0177.undo.public_item.sql index 31ab4da9f..36c5f8165 100755 --- a/packages/db/migrations/0177.undo.public_item.sql +++ b/packages/db/migrations/0177.undo.public_item.sql @@ -6,8 +6,9 @@ BEGIN; DROP TABLE omnivore.public_item_interactions; DROP TABLE omnivore.public_item_stats; -DROP TABLE omnivore.public_item_features; DROP TABLE omnivore.public_item; DROP TABLE omnivore.public_item_source; +ALTER TABLE omnivore.library_item DROP COLUMN seen_at; + COMMIT;