feat: paginate and merge frequent activities (#274)
* feat: paginate and merge frequent activities * feat: due date activities * fix: requested changes fixes * refactor: slight reshuffle and improve types * chore: translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
@@ -9,6 +9,7 @@ import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
|
||||
@@ -670,6 +671,81 @@ export const cardRouter = createTRPCRouter({
|
||||
|
||||
return { ...result, attachments: [] };
|
||||
}),
|
||||
getActivities: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get paginated card activities",
|
||||
method: "GET",
|
||||
path: "/cards/{cardPublicId}/activities",
|
||||
description:
|
||||
"Retrieves paginated activities for a card with merged frequent changes",
|
||||
tags: ["Cards"],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
limit: z.number().min(1).max(100).optional().default(10),
|
||||
cursor: z.string().datetime().optional(), // ISO datetime string
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
activities: z.array(
|
||||
z.custom<
|
||||
NonNullable<
|
||||
Awaited<
|
||||
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
|
||||
>
|
||||
>["activities"][number]
|
||||
>(),
|
||||
),
|
||||
hasMore: z.boolean(),
|
||||
nextCursor: z.string().datetime().nullable(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (card.workspaceVisibility === "private") {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
}
|
||||
|
||||
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
||||
const result = await cardActivityRepo.getPaginatedActivities(
|
||||
ctx.db,
|
||||
card.id,
|
||||
{
|
||||
limit: input.limit,
|
||||
cursor,
|
||||
},
|
||||
);
|
||||
|
||||
const mergedActivities = mergeActivities(result.activities);
|
||||
|
||||
return {
|
||||
activities: mergedActivities,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor?.toISOString() ?? null,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RouterInputs, RouterOutputs } from "../index";
|
||||
|
||||
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
|
||||
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
|
||||
export type GetCardActivitiesOutput = RouterOutputs["card"]["getActivities"];
|
||||
export type UpdateBoardInput = RouterInputs["board"]["update"];
|
||||
export type NewLabelInput = RouterInputs["label"]["create"];
|
||||
export type NewListInput = RouterInputs["list"]["create"];
|
||||
|
||||
109
packages/api/src/utils/activities.ts
Normal file
109
packages/api/src/utils/activities.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { PaginatedActivitiesResult } from "@kan/db/repository/cardActivity.repo";
|
||||
import type { ActivityType } from "@kan/db/schema";
|
||||
|
||||
type ActivityBase = PaginatedActivitiesResult["activities"][number];
|
||||
|
||||
interface Activity extends ActivityBase {
|
||||
mergeCount?: number; // number of activities merged into this one
|
||||
mergedLabels?: string[]; // list of label names when merging label activities
|
||||
}
|
||||
|
||||
// types that can be merged with simple count
|
||||
const MERGEABLE_COUNT_TYPES: readonly ActivityType[] = [
|
||||
"card.updated.description",
|
||||
];
|
||||
|
||||
// types that merge with a list of items
|
||||
const MERGEABLE_LIST_TYPES: readonly ActivityType[] = [
|
||||
"card.updated.label.added",
|
||||
"card.updated.label.removed",
|
||||
];
|
||||
|
||||
const MERGE_TIME_WINDOW_MS = 5 * 60 * 1000; // 5 minutes window for merging activities
|
||||
|
||||
export function mergeActivities(activities: Activity[]): Activity[] {
|
||||
if (activities.length === 0) return [];
|
||||
|
||||
const merged: Activity[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < activities.length) {
|
||||
const current = activities[i];
|
||||
if (!current) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentType = current.type;
|
||||
|
||||
const isCountMergeable = MERGEABLE_COUNT_TYPES.includes(currentType);
|
||||
const isListMergeable = MERGEABLE_LIST_TYPES.includes(currentType);
|
||||
const canMerge = (isCountMergeable || isListMergeable) && current.user?.id;
|
||||
|
||||
if (!canMerge) {
|
||||
merged.push(current);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const group: Activity[] = [current];
|
||||
let j = i + 1;
|
||||
|
||||
while (j < activities.length) {
|
||||
const next = activities[j];
|
||||
if (!next) break;
|
||||
|
||||
const timeDiff =
|
||||
new Date(next.createdAt).getTime() -
|
||||
new Date(current.createdAt).getTime();
|
||||
|
||||
if (
|
||||
next.type === current.type &&
|
||||
next.user?.id === current.user?.id &&
|
||||
timeDiff >= 0 && // next is newer or same time
|
||||
timeDiff <= MERGE_TIME_WINDOW_MS
|
||||
) {
|
||||
group.push(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (group.length > 1) {
|
||||
const oldest = group[0];
|
||||
const latest = group[group.length - 1];
|
||||
|
||||
if (oldest && latest) {
|
||||
if (isListMergeable) {
|
||||
const labelNames = group
|
||||
.map((a) => a.label?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
merged.push({
|
||||
...oldest,
|
||||
createdAt: oldest.createdAt,
|
||||
mergeCount: group.length,
|
||||
mergedLabels: labelNames,
|
||||
});
|
||||
} else {
|
||||
merged.push({
|
||||
...oldest,
|
||||
createdAt: oldest.createdAt,
|
||||
toDescription: latest.toDescription,
|
||||
fromDescription: oldest.fromDescription,
|
||||
mergeCount: group.length,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
merged.push(current);
|
||||
}
|
||||
} else {
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
i = j;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
Reference in New Issue
Block a user