Merge pull request #3014 from omnivore-app/feature/delete-user-cron
feat: create an api for the cronjob trigger which cleans up the soft deleted users
This commit is contained in:
3
.github/workflows/run-tests.yaml
vendored
3
.github/workflows/run-tests.yaml
vendored
@ -65,8 +65,9 @@ jobs:
|
|||||||
yarn install --frozen-lockfile
|
yarn install --frozen-lockfile
|
||||||
- name: Database Migration
|
- name: Database Migration
|
||||||
run: |
|
run: |
|
||||||
|
psql -h localhost -p ${{ job.services.postgres.ports[5432] }} -U postgres -c "CREATE USER app_user WITH ENCRYPTED PASSWORD 'app_pass';"
|
||||||
yarn workspace @omnivore/db migrate
|
yarn workspace @omnivore/db migrate
|
||||||
psql -h localhost -p ${{ job.services.postgres.ports[5432] }} -U postgres -c "CREATE USER app_user WITH ENCRYPTED PASSWORD 'app_pass';GRANT omnivore_user to app_user;"
|
psql -h localhost -p ${{ job.services.postgres.ports[5432] }} -U postgres -c "GRANT omnivore_user to app_user;"
|
||||||
env:
|
env:
|
||||||
PG_HOST: localhost
|
PG_HOST: localhost
|
||||||
PG_PORT: ${{ job.services.postgres.ports[5432] }}
|
PG_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import * as httpContext from 'express-http-context2'
|
|||||||
import { EntityManager, EntityTarget, Repository } from 'typeorm'
|
import { EntityManager, EntityTarget, Repository } from 'typeorm'
|
||||||
import { appDataSource } from '../data_source'
|
import { appDataSource } from '../data_source'
|
||||||
import { Claims } from '../resolvers/types'
|
import { Claims } from '../resolvers/types'
|
||||||
|
import { SetClaimsRole } from '../utils/dictionary'
|
||||||
|
|
||||||
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
|
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
|
||||||
return repository.metadata.columns.map(
|
return repository.metadata.columns.map(
|
||||||
@ -12,8 +13,10 @@ export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
|
|||||||
export const setClaims = async (
|
export const setClaims = async (
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
uid = '00000000-0000-0000-0000-000000000000',
|
uid = '00000000-0000-0000-0000-000000000000',
|
||||||
dbRole = 'omnivore_user'
|
userRole = 'user'
|
||||||
): Promise<unknown> => {
|
): Promise<unknown> => {
|
||||||
|
const dbRole =
|
||||||
|
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
|
||||||
return manager.query('SELECT * from omnivore.set_claims($1, $2)', [
|
return manager.query('SELECT * from omnivore.set_claims($1, $2)', [
|
||||||
uid,
|
uid,
|
||||||
dbRole,
|
dbRole,
|
||||||
|
|||||||
70
packages/api/src/routers/svc/user.ts
Normal file
70
packages/api/src/routers/svc/user.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import cors from 'cors'
|
||||||
|
import express from 'express'
|
||||||
|
import { LessThan } from 'typeorm'
|
||||||
|
import { StatusType } from '../../entity/user'
|
||||||
|
import { readPushSubscription } from '../../pubsub'
|
||||||
|
import { deleteUsers } from '../../services/user'
|
||||||
|
import { corsConfig } from '../../utils/corsConfig'
|
||||||
|
import { logger } from '../../utils/logger'
|
||||||
|
|
||||||
|
type CleanupMessage = {
|
||||||
|
subDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCleanupMessage = (obj: any): obj is CleanupMessage =>
|
||||||
|
'subDays' in obj && !isNaN(obj.subDays)
|
||||||
|
|
||||||
|
const getCleanupMessage = (msgStr: string): CleanupMessage => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(msgStr) as unknown
|
||||||
|
if (isCleanupMessage(obj)) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('error deserializing event: ', { msgStr, err })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subDays: 0, // default to 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userServiceRouter() {
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.post('/prune', cors<express.Request>(corsConfig), async (req, res) => {
|
||||||
|
logger.info('prune soft deleted users')
|
||||||
|
|
||||||
|
const { message: msgStr, expired } = readPushSubscription(req)
|
||||||
|
|
||||||
|
if (!msgStr) {
|
||||||
|
return res.status(200).send('Bad Request')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
logger.info('discarding expired message')
|
||||||
|
return res.status(200).send('Expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupMessage = getCleanupMessage(msgStr)
|
||||||
|
const subTime = cleanupMessage.subDays * 1000 * 60 * 60 * 24 // convert days to milliseconds
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteUsers({
|
||||||
|
status: StatusType.Deleted,
|
||||||
|
updatedAt: LessThan(new Date(Date.now() - subTime)), // subDays ago
|
||||||
|
})
|
||||||
|
logger.info('prune result', result)
|
||||||
|
|
||||||
|
return res.sendStatus(200)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('error prune users', error)
|
||||||
|
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ import { newsletterServiceRouter } from './routers/svc/newsletters'
|
|||||||
// import { remindersServiceRouter } from './routers/svc/reminders'
|
// import { remindersServiceRouter } from './routers/svc/reminders'
|
||||||
import { rssFeedRouter } from './routers/svc/rss_feed'
|
import { rssFeedRouter } from './routers/svc/rss_feed'
|
||||||
import { uploadServiceRouter } from './routers/svc/upload'
|
import { uploadServiceRouter } from './routers/svc/upload'
|
||||||
|
import { userServiceRouter } from './routers/svc/user'
|
||||||
import { webhooksServiceRouter } from './routers/svc/webhooks'
|
import { webhooksServiceRouter } from './routers/svc/webhooks'
|
||||||
import { textToSpeechRouter } from './routers/text_to_speech'
|
import { textToSpeechRouter } from './routers/text_to_speech'
|
||||||
import { userRouter } from './routers/user_router'
|
import { userRouter } from './routers/user_router'
|
||||||
@ -121,6 +122,7 @@ export const createApp = (): {
|
|||||||
app.use('/svc/pubsub/webhooks', webhooksServiceRouter())
|
app.use('/svc/pubsub/webhooks', webhooksServiceRouter())
|
||||||
app.use('/svc/pubsub/integrations', integrationsServiceRouter())
|
app.use('/svc/pubsub/integrations', integrationsServiceRouter())
|
||||||
app.use('/svc/pubsub/rss-feed', rssFeedRouter())
|
app.use('/svc/pubsub/rss-feed', rssFeedRouter())
|
||||||
|
app.use('/svc/pubsub/user', userServiceRouter())
|
||||||
// app.use('/svc/reminders', remindersServiceRouter())
|
// app.use('/svc/reminders', remindersServiceRouter())
|
||||||
app.use('/svc/email-attachment', emailAttachmentRouter())
|
app.use('/svc/email-attachment', emailAttachmentRouter())
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { DeepPartial, FindOptionsWhere, In } from 'typeorm'
|
||||||
import { StatusType, User } from '../entity/user'
|
import { StatusType, User } from '../entity/user'
|
||||||
import { authTrx } from '../repository'
|
import { authTrx } from '../repository'
|
||||||
import { userRepository } from '../repository/user'
|
import { userRepository } from '../repository/user'
|
||||||
|
import { SetClaimsRole } from '../utils/dictionary'
|
||||||
|
|
||||||
export const deleteUser = async (userId: string) => {
|
export const deleteUser = async (userId: string) => {
|
||||||
await authTrx(
|
await authTrx(
|
||||||
@ -20,6 +22,28 @@ export const updateUser = async (userId: string, update: Partial<User>) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findUser = async (id: string): Promise<User | null> => {
|
export const findActiveUser = async (id: string): Promise<User | null> => {
|
||||||
return userRepository.findOneBy({ id, status: StatusType.Active })
|
return userRepository.findOneBy({ id, status: StatusType.Active })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const findUsersById = async (ids: string[]): Promise<User[]> => {
|
||||||
|
return userRepository.findBy({ id: In(ids) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteUsers = async (criteria: FindOptionsWhere<User>) => {
|
||||||
|
return authTrx(
|
||||||
|
async (t) => t.getRepository(User).delete(criteria),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
SetClaimsRole.ADMIN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUsers = async (users: DeepPartial<User>[]) => {
|
||||||
|
return authTrx(
|
||||||
|
async (t) => t.getRepository(User).save(users),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
SetClaimsRole.ADMIN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
UpdateUserProfileErrorCode,
|
UpdateUserProfileErrorCode,
|
||||||
} from '../../src/generated/graphql'
|
} from '../../src/generated/graphql'
|
||||||
import { findProfile } from '../../src/services/profile'
|
import { findProfile } from '../../src/services/profile'
|
||||||
import { deleteUser, findUser } from '../../src/services/user'
|
import { deleteUser, findActiveUser } from '../../src/services/user'
|
||||||
import { hashPassword } from '../../src/utils/auth'
|
import { hashPassword } from '../../src/utils/auth'
|
||||||
import { createTestUser } from '../db'
|
import { createTestUser } from '../db'
|
||||||
import { generateFakeUuid, graphqlRequest, request } from '../util'
|
import { generateFakeUuid, graphqlRequest, request } from '../util'
|
||||||
@ -98,7 +98,7 @@ describe('User API', () => {
|
|||||||
|
|
||||||
it('updates user and responds with status code 200', async () => {
|
it('updates user and responds with status code 200', async () => {
|
||||||
const response = await graphqlRequest(query, authToken).expect(200)
|
const response = await graphqlRequest(query, authToken).expect(200)
|
||||||
const user = await findUser(response.body.data.updateUser.user.id)
|
const user = await findActiveUser(response.body.data.updateUser.user.id)
|
||||||
expect(user?.name).to.eql(name)
|
expect(user?.name).to.eql(name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
65
packages/api/test/routers/user.test.ts
Normal file
65
packages/api/test/routers/user.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { expect } from 'chai'
|
||||||
|
import 'mocha'
|
||||||
|
import { In } from 'typeorm'
|
||||||
|
import { StatusType } from '../../src/entity/user'
|
||||||
|
import {
|
||||||
|
createUsers,
|
||||||
|
deleteUsers,
|
||||||
|
findUsersById,
|
||||||
|
} from '../../src/services/user'
|
||||||
|
import { request } from '../util'
|
||||||
|
|
||||||
|
describe('User Service Router', () => {
|
||||||
|
const token = process.env.PUBSUB_VERIFICATION_TOKEN || ''
|
||||||
|
|
||||||
|
describe('prune', () => {
|
||||||
|
let toDeleteUserIds: string[] = []
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// create test users
|
||||||
|
const users = await createUsers([
|
||||||
|
{
|
||||||
|
name: 'user_1',
|
||||||
|
email: 'user_1@omnivore.app',
|
||||||
|
status: StatusType.Deleted,
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), // 2 days ago
|
||||||
|
source: 'GOOGLE',
|
||||||
|
sourceUserId: '123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_2',
|
||||||
|
email: 'user_2@omnivore.app',
|
||||||
|
status: StatusType.Deleted,
|
||||||
|
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), // 2 days ago
|
||||||
|
source: 'GOOGLE',
|
||||||
|
sourceUserId: '456',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
toDeleteUserIds = users.map((u) => u.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// delete test users
|
||||||
|
await deleteUsers({ id: In(toDeleteUserIds) })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prunes soft deleted users a day ago', async () => {
|
||||||
|
const data = {
|
||||||
|
message: {
|
||||||
|
data: Buffer.from(
|
||||||
|
JSON.stringify({ subDays: 1 }) // 1 day ago
|
||||||
|
).toString('base64'),
|
||||||
|
publishTime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await request
|
||||||
|
.post('/svc/pubsub/user/prune?token=' + token)
|
||||||
|
.send(data)
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const deletedUsers = await findUsersById(toDeleteUserIds)
|
||||||
|
expect(deletedUsers.length).to.equal(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
9
packages/db/migrations/0141.do.add_index_for_cleanup_to_user.sql
Executable file
9
packages/db/migrations/0141.do.add_index_for_cleanup_to_user.sql
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
-- Type: DO
|
||||||
|
-- Name: add_index_for_cleanup_to_user
|
||||||
|
-- Description: Add index of status and updated_at to omnivore.user table for cleanup of deleted users
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_status_updated_at_idx ON omnivore.user (status, updated_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
9
packages/db/migrations/0141.undo.add_index_for_cleanup_to_user.sql
Executable file
9
packages/db/migrations/0141.undo.add_index_for_cleanup_to_user.sql
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
-- Type: UNDO
|
||||||
|
-- Name: add_index_for_cleanup_to_user
|
||||||
|
-- Description: Add index of status and updated_at to omnivore.user table for cleanup of deleted users
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS user_status_updated_at_idx;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
19
packages/db/migrations/0142.do.create_omnivore_admin_role.sql
Executable file
19
packages/db/migrations/0142.do.create_omnivore_admin_role.sql
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
-- Type: DO
|
||||||
|
-- Name: create_omnivore_admin_role
|
||||||
|
-- Description: Create omnivore_admin role with admin permissions
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE ROLE omnivore_admin;
|
||||||
|
|
||||||
|
GRANT omnivore_admin TO app_user;
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON SCHEMA omnivore TO omnivore_admin;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA omnivore TO omnivore_admin;
|
||||||
|
|
||||||
|
CREATE POLICY user_admin_policy on omnivore.user
|
||||||
|
FOR ALL
|
||||||
|
TO omnivore_admin
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
16
packages/db/migrations/0142.undo.create_omnivore_admin_role.sql
Executable file
16
packages/db/migrations/0142.undo.create_omnivore_admin_role.sql
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
-- Type: UNDO
|
||||||
|
-- Name: create_omnivore_admin_role
|
||||||
|
-- Description: Create omnivore_admin role with admin permissions
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP POLICY user_admin_policy ON omnivore.user;
|
||||||
|
|
||||||
|
REVOKE ALL PRIVILEGES on omnivore.user from omnivore_admin;
|
||||||
|
REVOKE ALL PRIVILEGES on SCHEMA omnivore from omnivore_admin;
|
||||||
|
|
||||||
|
DROP OWNED BY omnivore_admin;
|
||||||
|
|
||||||
|
DROP ROLE IF EXISTS omnivore_admin;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user