diff --git a/.github/workflows/lint-migrations.yml b/.github/workflows/lint-migrations.yml index 00350abcd0..c731007892 100644 --- a/.github/workflows/lint-migrations.yml +++ b/.github/workflows/lint-migrations.yml @@ -16,7 +16,9 @@ jobs: run: | modified_migrations=$(git diff --diff-filter=d --name-only main 'packages/db/migrations/*.do.*.sql') echo "$modified_migrations" + echo "text<> $GITHUB_OUTPUT echo "file_names=$modified_migrations" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT id: modified-migrations - uses: sbdchd/squawk-action@v1 with: diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts index 0df30e6ac2..fac2f45daa 100644 --- a/packages/api/src/entity/user.ts +++ b/packages/api/src/entity/user.ts @@ -23,6 +23,7 @@ export enum StatusType { Active = 'ACTIVE', Pending = 'PENDING', Deleted = 'DELETED', + Archived = 'ARCHIVED', } @Entity() diff --git a/packages/api/src/jobs/email/inbound_emails.ts b/packages/api/src/jobs/email/inbound_emails.ts index 5a047d51b9..12528e8843 100644 --- a/packages/api/src/jobs/email/inbound_emails.ts +++ b/packages/api/src/jobs/email/inbound_emails.ts @@ -23,10 +23,10 @@ import { enqueueSendEmail } from '../../utils/createTask' import { generateSlug, isUrl } from '../../utils/helpers' import { logger } from '../../utils/logger' import { - parseEmailAddress, - isProbablyArticle, - getTitleFromEmailSubject, generateUniqueUrl, + getTitleFromEmailSubject, + isProbablyArticle, + parseEmailAddress, } from '../../utils/parser' import { generateUploadFilePathName, diff --git a/packages/api/src/jobs/rss/refreshAllFeeds.ts b/packages/api/src/jobs/rss/refreshAllFeeds.ts index 29ef6f8942..0f2cd82955 100644 --- a/packages/api/src/jobs/rss/refreshAllFeeds.ts +++ b/packages/api/src/jobs/rss/refreshAllFeeds.ts @@ -40,12 +40,11 @@ export const refreshAllFeeds = async (db: DataSource): Promise => { FROM omnivore.subscriptions s INNER JOIN - omnivore.user u ON u.id = s.user_id + omnivore.user u ON u.id = s.user_id AND u.status = $4 WHERE s.type = $1 AND s.status = $2 AND (s.scheduled_at <= NOW() OR s.scheduled_at IS NULL) - AND u.status = $4 GROUP BY url `, diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index c34b628ab0..ed88d92377 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -35,7 +35,7 @@ import { } from '../../utils/auth' import { corsConfig } from '../../utils/corsConfig' import { logger } from '../../utils/logger' -import { DEFAULT_HOME_PATH } from '../../utils/navigation' +import { ARCHIVE_ACCOUNT_PATH, DEFAULT_HOME_PATH } from '../../utils/navigation' import { hourlyLimiter } from '../../utils/rate_limit' import { verifyChallengeRecaptcha } from '../../utils/recaptcha' import { createSsoToken, ssoRedirectURL } from '../../utils/sso' @@ -378,9 +378,11 @@ export function authRouter() { } } - redirectUri = redirectUri - ? redirectUri - : `${env.client.url}${DEFAULT_HOME_PATH}` + if (user.status === StatusType.Archived) { + redirectUri = `${env.client.url}${ARCHIVE_ACCOUNT_PATH}` + } + + redirectUri = redirectUri ?? `${env.client.url}${DEFAULT_HOME_PATH}` const message = res.get('Message') if (message) { diff --git a/packages/api/src/routers/auth/google_auth.ts b/packages/api/src/routers/auth/google_auth.ts index 21fcafba1b..65cb2efcaa 100644 --- a/packages/api/src/routers/auth/google_auth.ts +++ b/packages/api/src/routers/auth/google_auth.ts @@ -6,10 +6,10 @@ import { env, homePageURL } from '../../env' import { LoginErrorCode } from '../../generated/graphql' import { userRepository } from '../../repository/user' import { logger } from '../../utils/logger' +import { ARCHIVE_ACCOUNT_PATH, DEFAULT_HOME_PATH } from '../../utils/navigation' import { createSsoToken, ssoRedirectURL } from '../../utils/sso' import { DecodeTokenResult } from './auth_types' import { createPendingUserToken, createWebAuthToken } from './jwt_helpers' -import { DEFAULT_HOME_PATH } from '../../utils/navigation' export const googleAuthMobile = (): OAuth2Client => new google.auth.OAuth2(env.google.auth.clientId, env.google.auth.secret) @@ -132,7 +132,6 @@ export async function handleGoogleWebAuth( const user = await userRepository.findOneBy({ email, source: 'GOOGLE', - status: StatusType.Active, }) const userId = user?.id @@ -158,15 +157,18 @@ export async function handleGoogleWebAuth( } } + let redirectURL = `${baseURL()}${ + user.status === StatusType.Archived + ? ARCHIVE_ACCOUNT_PATH + : DEFAULT_HOME_PATH + }` + const authToken = await createWebAuthToken(userId) if (authToken) { - const ssoToken = createSsoToken( - authToken, - `${baseURL()}${DEFAULT_HOME_PATH}` - ) - const redirectURL = isVercel - ? ssoRedirectURL(ssoToken) - : `${baseURL()}${DEFAULT_HOME_PATH}` + if (isVercel) { + const ssoToken = createSsoToken(authToken, redirectURL) + redirectURL = ssoRedirectURL(ssoToken) + } return { authToken, diff --git a/packages/api/src/routers/export_router.ts b/packages/api/src/routers/export_router.ts index 7b2a82ba8c..aac22e172a 100644 --- a/packages/api/src/routers/export_router.ts +++ b/packages/api/src/routers/export_router.ts @@ -3,7 +3,6 @@ import express, { Router } from 'express' import { TaskState } from '../generated/graphql' import { jobStateToTaskState } from '../queue-processor' import { countExportsWithin24Hours, saveExport } from '../services/export' -import { sendExportJobEmail } from '../services/send_emails' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { queueExportJob } from '../utils/createTask' diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 9a945a3a4f..f4cc5457ba 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -1039,7 +1039,11 @@ export const updateLibraryItemReadingProgress = async ( } const updatedItem = result[0][0] - await pubsub.entityUpdated(EntityType.ITEM, updatedItem, userId) + const readingProgress = updatedItem.readingProgressBottomPercent + if (readingProgress === 0 || readingProgress === 100) { + // only send PAGE_UPDATED event if users mark item as read or unread + await pubsub.entityUpdated(EntityType.ITEM, updatedItem, userId) + } return updatedItem } diff --git a/packages/api/src/services/newsletters.ts b/packages/api/src/services/newsletters.ts index eb30ac2670..2812490577 100644 --- a/packages/api/src/services/newsletters.ts +++ b/packages/api/src/services/newsletters.ts @@ -1,5 +1,6 @@ import { nanoid } from 'nanoid' import { NewsletterEmail } from '../entity/newsletter_email' +import { StatusType } from '../entity/user' import { env } from '../env' import { CreateNewsletterEmailErrorCode, @@ -91,7 +92,12 @@ export const findNewsletterEmailByAddress = async ( const address = parsedAddress(emailAddress) return getRepository(NewsletterEmail) .createQueryBuilder('newsletter_email') - .innerJoinAndSelect('newsletter_email.user', 'user') + .innerJoinAndSelect( + 'newsletter_email.user', + 'user', + 'user.status = :status', + { status: StatusType.Active } + ) .where('LOWER(address) = :address', { address: address.toLowerCase() }) .getOne() } diff --git a/packages/api/src/services/rules.ts b/packages/api/src/services/rules.ts index c0c394fb4c..5e380b2ab5 100644 --- a/packages/api/src/services/rules.ts +++ b/packages/api/src/services/rules.ts @@ -1,5 +1,6 @@ import { ArrayContains, ILike, IsNull, Not } from 'typeorm' import { Rule, RuleAction, RuleEventType } from '../entity/rule' +import { StatusType } from '../entity/user' import { authTrx, getRepository } from '../repository' export const createRule = async ( @@ -62,7 +63,7 @@ export const findEnabledRules = async ( eventType: RuleEventType ) => { return getRepository(Rule).findBy({ - user: { id: userId }, + user: { id: userId, status: StatusType.Active }, enabled: true, eventTypes: ArrayContains([eventType]), failedAt: IsNull(), // only rules that have not failed diff --git a/packages/api/src/services/user.ts b/packages/api/src/services/user.ts index c5dc336b9e..32f3edf121 100644 --- a/packages/api/src/services/user.ts +++ b/packages/api/src/services/user.ts @@ -81,38 +81,20 @@ export const createUsers = async (users: DeepPartial[]) => { export const batchDelete = async (criteria: FindOptionsWhere) => { const userQb = getRepository(User).createQueryBuilder().where(criteria) - const userCountSql = queryBuilderToRawSql(userQb.select('COUNT(1)')) - const userSubQuery = queryBuilderToRawSql( - userQb.select('array_agg(id::UUID) into user_ids') - ) + const batchSize = 100 + const userSubQuery = queryBuilderToRawSql(userQb.select('id').take(batchSize)) - const batchSize = 1000 const sql = ` - -- Set batch size DO $$ - DECLARE - batch_size INT := ${batchSize}; - user_ids UUID[]; BEGIN - -- Loop through batches of users - FOR i IN 0..CEIL((${userCountSql}) * 1.0 / batch_size) - 1 LOOP - -- GET batch of user ids - ${userSubQuery} LIMIT batch_size; - - -- Loop through batches of items - FOR j IN 0..CEIL((SELECT COUNT(1) FROM omnivore.library_item WHERE user_id = ANY(user_ids)) * 1.0 / batch_size) - 1 LOOP - -- Delete batch of items - DELETE FROM omnivore.library_item - WHERE id = ANY( - SELECT id - FROM omnivore.library_item - WHERE user_id = ANY(user_ids) - LIMIT batch_size - ); - END LOOP; - - -- Delete the batch of users - DELETE FROM omnivore.user WHERE id = ANY(user_ids); + LOOP + DELETE FROM omnivore.user + WHERE id IN (${userSubQuery}); + + EXIT WHEN NOT FOUND; + + -- Avoid overwhelming the server + PERFORM pg_sleep(0.1); END LOOP; END $$ ` diff --git a/packages/api/src/utils/navigation.ts b/packages/api/src/utils/navigation.ts index 0416cc573b..c2a39c9039 100644 --- a/packages/api/src/utils/navigation.ts +++ b/packages/api/src/utils/navigation.ts @@ -1 +1,2 @@ export const DEFAULT_HOME_PATH = '/home' +export const ARCHIVE_ACCOUNT_PATH = '/account-archived' diff --git a/packages/api/test/resolvers/newsletters.test.ts b/packages/api/test/resolvers/newsletters.test.ts index 6f48e3ea62..8fcc464f00 100644 --- a/packages/api/test/resolvers/newsletters.test.ts +++ b/packages/api/test/resolvers/newsletters.test.ts @@ -30,7 +30,7 @@ describe('Newsletters API', () => { .post('/local/debug/fake-user-login') .send({ fakeEmail: user.email }) - authToken = res.body.authToken + authToken = res.body.authToken as string }) after(async () => { @@ -65,14 +65,8 @@ describe('Newsletters API', () => { before(async () => { // create test newsletter emails - const newsletterEmail1 = await createNewsletterEmail( - user.id, - 'Test_email_address_1@omnivore.app' - ) - const newsletterEmail2 = await createNewsletterEmail( - user.id, - 'Test_email_address_2@omnivore.app' - ) + const newsletterEmail1 = await createNewsletterEmail(user.id) + const newsletterEmail2 = await createNewsletterEmail(user.id) newsletterEmails = [newsletterEmail1, newsletterEmail2] // create testing subscriptions @@ -89,7 +83,9 @@ describe('Newsletters API', () => { it('responds with newsletter emails sort by created_at desc', async () => { const response = await graphqlRequest(query, authToken).expect(200) expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-call response.body.data.newsletterEmails.newsletterEmails.map((e: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return { ...e, createdAt: @@ -124,10 +120,7 @@ describe('Newsletters API', () => { before(async () => { // create test newsletter emails - newsletterEmail = await createNewsletterEmail( - user.id, - 'Test_email_address_1@omnivore.app' - ) + newsletterEmail = await createNewsletterEmail(user.id) // create unsubscribed subscriptions await createSubscription( @@ -190,7 +183,7 @@ describe('Newsletters API', () => { const response = await graphqlRequest(query, authToken, { input: { folder, - } + }, }).expect(200) const newsletterEmail = await findNewsletterEmailById( response.body.data.createNewsletterEmail.newsletterEmail.id @@ -239,10 +232,7 @@ describe('Newsletters API', () => { context('when newsletter email exists', () => { before(async () => { // create test newsletter emails - const newsletterEmail = await createNewsletterEmail( - user.id, - 'Test_email_address_1@omnivore.app' - ) + const newsletterEmail = await createNewsletterEmail(user.id) newsletterEmailId = newsletterEmail.id }) @@ -254,7 +244,7 @@ describe('Newsletters API', () => { it('responds with status code 200', async () => { const response = await graphqlRequest(query, authToken).expect(200) const newsletterEmail = await findNewsletterEmailByAddress( - response.body.data.deleteNewsletterEmail.newsletterEmail.id + response.body.data.deleteNewsletterEmail.newsletterEmail.address ) expect(newsletterEmail).to.be.null }) diff --git a/packages/db/migrations/0187.do.allow_admin_to_delete_filters.sql b/packages/db/migrations/0187.do.allow_admin_to_delete_filters.sql new file mode 100755 index 0000000000..b60d73dd10 --- /dev/null +++ b/packages/db/migrations/0187.do.allow_admin_to_delete_filters.sql @@ -0,0 +1,14 @@ +-- Type: DO +-- Name: allow_admin_to_delete_filters +-- Description: Add permissions to delete data from filters table to the omnivore_admin role + +BEGIN; + +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.filters TO omnivore_admin; + +CREATE POLICY filters_admin_policy on omnivore.filters + FOR ALL + TO omnivore_admin + USING (true); + +COMMIT; diff --git a/packages/db/migrations/0187.undo.allow_admin_to_delete_filters.sql b/packages/db/migrations/0187.undo.allow_admin_to_delete_filters.sql new file mode 100755 index 0000000000..4266657921 --- /dev/null +++ b/packages/db/migrations/0187.undo.allow_admin_to_delete_filters.sql @@ -0,0 +1,11 @@ +-- Type: UNDO +-- Name: allow_admin_to_delete_filters +-- Description: Add permissions to delete data from filters table to the omnivore_admin role + +BEGIN; + +DROP POLICY filters_admin_policy on omnivore.filters; + +REVOKE SELECT, INSERT, UPDATE, DELETE ON omnivore.filters FROM omnivore_admin; + +COMMIT; diff --git a/packages/db/migrations/0188.do.add_archived_status_to_user.sql b/packages/db/migrations/0188.do.add_archived_status_to_user.sql new file mode 100755 index 0000000000..06518f4b98 --- /dev/null +++ b/packages/db/migrations/0188.do.add_archived_status_to_user.sql @@ -0,0 +1,9 @@ +-- Type: DO +-- Name: add_archived_status_to_user +-- Description: Add ARCHIVED status to the user table + +BEGIN; + +ALTER TYPE user_status_type ADD VALUE IF NOT EXISTS 'ARCHIVED'; + +COMMIT; diff --git a/packages/db/migrations/0188.undo.add_archived_status_to_user.sql b/packages/db/migrations/0188.undo.add_archived_status_to_user.sql new file mode 100755 index 0000000000..e0bd8947bb --- /dev/null +++ b/packages/db/migrations/0188.undo.add_archived_status_to_user.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: add_archived_status_to_user +-- Description: Add ARCHIVED status to the user table + +BEGIN; + +ALTER TYPE user_status_type DROP VALUE IF EXISTS 'ARCHIVED'; + +COMMIT;