mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
feat(routines): add workspace-aware routine runs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
909e8cd4c8
38 changed files with 15468 additions and 250 deletions
|
|
@ -99,6 +99,11 @@ type IssueUserCommentStats = {
|
|||
myLastCommentAt: Date | null;
|
||||
lastExternalCommentAt: Date | null;
|
||||
};
|
||||
type IssueLastActivityStat = {
|
||||
issueId: string;
|
||||
latestCommentAt: Date | null;
|
||||
latestLogAt: Date | null;
|
||||
};
|
||||
type IssueUserContextInput = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
|
|
@ -262,8 +267,8 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
return sql<Date>`
|
||||
COALESCE(
|
||||
${lastExternalCommentAt},
|
||||
GREATEST(
|
||||
COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
|
||||
CASE
|
||||
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
|
||||
THEN ${issues.updatedAt}
|
||||
|
|
@ -273,6 +278,52 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||
`;
|
||||
}
|
||||
|
||||
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
|
||||
"issue.read_marked",
|
||||
"issue.read_unmarked",
|
||||
"issue.inbox_archived",
|
||||
"issue.inbox_unarchived",
|
||||
] as const;
|
||||
|
||||
function issueLatestCommentAtExpr(companyId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueComments.createdAt})
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function issueLatestLogAtExpr(companyId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${activityLog.createdAt})
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND ${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||
sql`, `,
|
||||
)})
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function issueCanonicalLastActivityAtExpr(companyId: string) {
|
||||
const latestCommentAt = issueLatestCommentAtExpr(companyId);
|
||||
const latestLogAt = issueLatestLogAtExpr(companyId);
|
||||
return sql<Date>`
|
||||
GREATEST(
|
||||
${issues.updatedAt},
|
||||
COALESCE(${latestCommentAt}, to_timestamp(0)),
|
||||
COALESCE(${latestLogAt}, to_timestamp(0))
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function unreadForUserCondition(companyId: string, userId: string) {
|
||||
const touchedCondition = touchedByUserCondition(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
|
|
@ -383,6 +434,19 @@ export function deriveIssueUserContext(
|
|||
};
|
||||
}
|
||||
|
||||
function latestIssueActivityAt(...values: Array<Date | string | null | undefined>): Date | null {
|
||||
const normalized = values
|
||||
.map((value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
})
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime());
|
||||
return normalized[0] ?? null;
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
|
|
@ -749,66 +813,158 @@ export function issueService(db: Db) {
|
|||
ELSE 6
|
||||
END
|
||||
`;
|
||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(...conditions))
|
||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||
.orderBy(
|
||||
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
||||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
);
|
||||
const withLabels = await withIssueLabels(db, rows);
|
||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||
const withRuns = withActiveRuns(withLabels, runMap);
|
||||
if (!contextUserId || withRuns.length === 0) {
|
||||
if (withRuns.length === 0) {
|
||||
return withRuns;
|
||||
}
|
||||
|
||||
const issueIds = withRuns.map((row) => row.id);
|
||||
const statsRows = await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
const [statsRows, readRows, lastActivityRows] = await Promise.all([
|
||||
contextUserId
|
||||
? db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId);
|
||||
const readRows = await db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
);
|
||||
.groupBy(issueComments.issueId)
|
||||
: Promise.resolve([]),
|
||||
contextUserId
|
||||
? db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId),
|
||||
db
|
||||
.select({
|
||||
issueId: activityLog.entityId,
|
||||
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, issueIds),
|
||||
sql`${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(activityLog.entityId),
|
||||
]).then(([commentRows, logRows]) => {
|
||||
const byIssueId = new Map<string, IssueLastActivityStat>();
|
||||
for (const row of commentRows) {
|
||||
byIssueId.set(row.issueId, {
|
||||
issueId: row.issueId,
|
||||
latestCommentAt: row.latestCommentAt,
|
||||
latestLogAt: null,
|
||||
});
|
||||
}
|
||||
for (const row of logRows) {
|
||||
const existing = byIssueId.get(row.issueId);
|
||||
if (existing) existing.latestLogAt = row.latestLogAt;
|
||||
else {
|
||||
byIssueId.set(row.issueId, {
|
||||
issueId: row.issueId,
|
||||
latestCommentAt: null,
|
||||
latestLogAt: row.latestLogAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byIssueId.values()];
|
||||
}),
|
||||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
const activity = lastActivityByIssueId.get(row.id);
|
||||
const lastActivityAt = latestIssueActivityAt(
|
||||
row.updatedAt,
|
||||
activity?.latestCommentAt ?? null,
|
||||
activity?.latestLogAt ?? null,
|
||||
) ?? row.updatedAt;
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||
|
||||
return withRuns.map((row) => ({
|
||||
...row,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
}));
|
||||
return withRuns.map((row) => {
|
||||
const activity = lastActivityByIssueId.get(row.id);
|
||||
const lastActivityAt = latestIssueActivityAt(
|
||||
row.updatedAt,
|
||||
activity?.latestCommentAt ?? null,
|
||||
activity?.latestLogAt ?? null,
|
||||
) ?? row.updatedAt;
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue