Files
speckle-server/packages/server/modules/activitystream/repositories/index.ts
T
Daniel Gak Anagrov ba3d9ade22 feat(activity): moved project roles to activity (#5019)
* feat: moved project roles to activity
* fix: added previous project role filed on activity
* fix: remvoke does not get logged as an actvity for every project in a workspace when removed from workspace
* fix: on delete user account, emit also seat and project event

Co-authored-by: Charles Driesler <chuck@speckle.systems>
2025-07-10 11:44:54 +02:00

326 lines
11 KiB
TypeScript

import {
GetActiveUserStreams,
GetActivities,
GetActivityCountByResourceId,
GetActivityCountByStreamId,
GetActivityCountByUserId,
GetResourceActivity,
GetStreamActivity,
GetTimelineCount,
GetUserActivity,
GetUserTimeline,
SaveActivity,
SaveStreamActivity
} from '@/modules/activitystream/domain/operations'
import {
StreamActivityRecord,
StreamScopeActivity
} from '@/modules/activitystream/helpers/types'
import {
Activity as ActivityModel,
StreamAcl,
StreamActivity
} from '@/modules/core/dbSchema'
import { Roles } from '@/modules/core/helpers/mainConstants'
import { StreamAclRecord } from '@/modules/core/helpers/types'
import {
createWebhookEventFactory,
getStreamWebhooksFactory
} from '@/modules/webhooks/repositories/webhooks'
import { dispatchStreamEventFactory } from '@/modules/webhooks/services/webhooks'
import { Knex } from 'knex'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import cryptoRandomString from 'crypto-random-string'
import { Activity } from '@/modules/activitystream/domain/types'
const tables = {
streamActivity: <T extends object = StreamActivityRecord>(db: Knex) =>
db<T>(StreamActivity.name),
streamAcl: (db: Knex) => db<StreamAclRecord>(StreamAcl.name)
}
export const geUserStreamActivityFactory =
({ db }: { db: Knex }) =>
async (
streamId: string,
start: Date,
end: Date,
filteredUser: string | null = null
): Promise<StreamScopeActivity[]> => {
let query = tables
.streamActivity<StreamScopeActivity>(db)
.where(StreamActivity.col.streamId, '=', streamId)
.whereBetween(StreamActivity.col.time, [start, end])
if (filteredUser) query = query.andWhereNot(StreamActivity.col.userId, filteredUser)
return await query
}
export const getActiveUserStreamsFactory =
({ db }: { db: Knex }): GetActiveUserStreams =>
async (start: Date, end: Date) => {
const query = tables
.streamAcl(db)
.select(StreamActivity.col.userId)
// creates the UserSteams type by aggregating the streamId-s, grouped by userId
.select(
db.raw(`array_agg(distinct ${StreamActivity.name}."streamId") as "streamIds"`)
)
.groupBy(StreamActivity.col.userId)
.join(
StreamActivity.name,
StreamActivity.col.streamId,
'=',
'stream_acl.resourceId'
)
.whereBetween(StreamActivity.col.time, [start, end])
// make sure archived users do not counted for activity
.join('server_acl', 'server_acl.userId', '=', StreamActivity.col.userId)
.whereNot('server_acl.role', '=', Roles.Server.ArchivedUser)
return (await query) as Awaited<ReturnType<GetActiveUserStreams>>
}
export const getStreamActivityFactory =
({ db }: { db: Knex }): GetStreamActivity =>
async ({ streamId, actionType, after, before, cursor, limit }) => {
if (!limit) {
limit = 200
}
const dbQuery = tables.streamActivity(db).where({ streamId })
if (actionType) dbQuery.andWhere({ actionType })
if (after) dbQuery.andWhere('time', '>', after)
if (before) dbQuery.andWhere('time', '<', before)
if (cursor) dbQuery.andWhere('time', '<', cursor)
dbQuery.orderBy('time', 'desc').limit(limit)
const results = await dbQuery.select('*')
return {
items: results,
cursor: results.length > 0 ? results[results.length - 1].time.toISOString() : null
}
}
export const getActivityCountByStreamIdFactory =
({ db }: { db: Knex }): GetActivityCountByStreamId =>
async ({ streamId, actionType, after, before }) => {
const query = tables.streamActivity(db).count().where({ streamId })
if (actionType) query.andWhere({ actionType })
if (after) query.andWhere('time', '>', after)
if (before) query.andWhere('time', '<', before)
const [res] = await query
return parseInt(res.count.toString())
}
export const getActivityCountByUserIdFactory =
({ db }: { db: Knex }): GetActivityCountByUserId =>
async ({ userId, actionType, after, before }) => {
const query = tables.streamActivity(db).count().where({ userId })
if (actionType) query.andWhere({ actionType })
if (after) query.andWhere('time', '>', after)
if (before) query.andWhere('time', '<', before)
const [res] = await query
return parseInt(res.count.toString())
}
export const getTimelineCountFactory =
({ db }: { db: Knex }): GetTimelineCount =>
async ({ userId, after, before }) => {
const query = tables
.streamAcl(db)
.count()
.innerJoin('stream_activity', {
'stream_acl.resourceId': 'stream_activity.streamId'
})
.where({ 'stream_acl.userId': userId })
if (after) query.andWhere('stream_activity.time', '>', after)
if (before) query.andWhere('stream_activity.time', '<', before)
const [res] = await query
return parseInt(res.count.toString())
}
export const getActivityCountByResourceIdFactory =
({ db }: { db: Knex }): GetActivityCountByResourceId =>
async ({ resourceId, actionType, after, before }) => {
const query = tables.streamActivity(db).count().where({ resourceId })
if (actionType) query.andWhere({ actionType })
if (after) query.andWhere('time', '>', after)
if (before) query.andWhere('time', '<', before)
const [res] = await query
return parseInt(res.count.toString())
}
export const getUserTimelineFactory =
({ db }: { db: Knex }): GetUserTimeline =>
async ({ userId, after, before, cursor, limit }) => {
if (!limit) {
limit = 200
}
let sqlFilters = ''
const sqlVariables = []
if (after) {
sqlFilters += ' AND time > ?'
sqlVariables.push(after)
}
if (before || cursor) {
sqlFilters += ' AND time < ?'
sqlVariables.push(before || cursor)
}
const dbRawQuery = `
SELECT act.*
FROM stream_acl acl
INNER JOIN stream_activity act ON acl."resourceId" = act."streamId"
WHERE acl."userId" = ? ${sqlFilters}
ORDER BY time DESC
LIMIT ?
`
sqlVariables.unshift(userId)
sqlVariables.push(limit)
const results = (await db.raw(dbRawQuery, sqlVariables)).rows
return {
items: results,
cursor: results.length > 0 ? results[results.length - 1].time.toISOString() : null
}
}
export const getResourceActivityFactory =
({ db }: { db: Knex }): GetResourceActivity =>
async ({ resourceType, resourceId, actionType, after, before, cursor, limit }) => {
if (!limit) {
limit = 200
}
const dbQuery = tables.streamActivity(db).where({ resourceType, resourceId })
if (actionType) dbQuery.andWhere({ actionType })
if (after) dbQuery.andWhere('time', '>', after)
if (before) dbQuery.andWhere('time', '<', before)
if (cursor) dbQuery.andWhere('time', '<', cursor)
dbQuery.orderBy('time', 'desc').limit(limit)
const results = await dbQuery.select('*')
return {
items: results,
cursor: results.length > 0 ? results[results.length - 1].time.toISOString() : null
}
}
export const getUserActivityFactory =
({ db }: { db: Knex }): GetUserActivity =>
async ({ userId, actionType, after, before, cursor, limit }) => {
if (!limit) {
limit = 200
}
const dbQuery = tables.streamActivity(db).where({ userId })
if (actionType) dbQuery.andWhere({ actionType })
if (after) dbQuery.andWhere('time', '>', after)
if (before) dbQuery.andWhere('time', '<', before)
if (cursor) dbQuery.andWhere('time', '<', cursor)
dbQuery.orderBy('time', 'desc').limit(limit)
const results = await dbQuery.select('*')
return {
items: results,
cursor: results.length > 0 ? results[results.length - 1].time.toISOString() : null
}
}
// TODO: this function should be a service
export const saveStreamActivityFactory =
({ db }: { db: Knex }): SaveStreamActivity =>
async ({ streamId, resourceType, resourceId, actionType, userId, info, message }) => {
const dbObject = {
streamId, // abc
resourceType, // "commit"
resourceId, // commit id
actionType, // "commit_receive"
userId, // populated by the api
info: JSON.stringify(info), // can be anything with conventions! (TBD)
message // something human understandable for frontend purposes mostly
}
await tables
.streamActivity<Omit<StreamActivityRecord, 'info'> & { info: string }>(db)
.insert(dbObject)
if (streamId) {
const webhooksPayload = {
streamId,
userId,
activityMessage: message,
event: {
// eslint-disable-next-line camelcase
event_name: actionType,
data: info
}
}
const projectDb = await getProjectDbClient({ projectId: streamId })
// yes, we're manually instantiating this thing here, but i do not want to go through all the places,
// where we're calling saveActivity!
// the whole activity module will need to be refactored to use the eventBus
await dispatchStreamEventFactory({
getStreamWebhooks: getStreamWebhooksFactory({ db: projectDb }),
getServerInfo: getServerInfoFactory({ db }),
getStream: getStreamFactory({ db: projectDb }),
createWebhookEvent: createWebhookEventFactory({ db: projectDb }),
getUser: getUserFactory({ db })
})({
streamId,
event: actionType,
eventPayload: webhooksPayload
})
}
}
export const saveActivityFactory =
({ db }: { db: Knex }): SaveActivity =>
async (activity) => {
const id = cryptoRandomString({ length: 10 })
const createdAt = new Date()
const [result] = await db<typeof activity & { id: string; createdAt: Date }>(
ActivityModel.name
).insert({ ...activity, id, createdAt }, '*')
return result
}
export const getActivitiesFactory =
({ db }: { db: Knex }): GetActivities =>
async (filters = {}): Promise<Activity[]> => {
const { workspaceId, projectId, eventType, userId } = filters
const q = db<Activity>(ActivityModel.name).select('*')
if (projectId) {
q.where(ActivityModel.col.contextResourceId, projectId).andWhere(
ActivityModel.col.contextResourceType,
'project'
)
}
if (workspaceId) {
q.where(ActivityModel.col.contextResourceId, workspaceId).andWhere(
ActivityModel.col.contextResourceType,
'workspace'
)
}
if (eventType) {
q.andWhere(ActivityModel.col.eventType, eventType)
}
if (userId) {
q.andWhere(ActivityModel.col.userId, userId)
}
return await q
}