* feat: Activity log for uploading attachments (closes #354) * chore: fix coding style * revert: upload spinning icon patch * refactor: add fallbacks, extended card_activity
This commit is contained in:
@@ -2,16 +2,16 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { Upload } from "@aws-sdk/lib-storage";
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
import { createS3Client } from "@kan/shared/utils";
|
|
||||||
import { assertPermission } from "@kan/api/utils/permissions";
|
|
||||||
|
|
||||||
|
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
@@ -115,9 +115,15 @@ export default withRateLimit(
|
|||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return res.status(500).json({ error: "Failed to create attachment" });
|
||||||
|
}
|
||||||
|
|
||||||
await cardActivityRepo.create(db, {
|
await cardActivityRepo.create(db, {
|
||||||
type: "card.updated.attachment.added",
|
type: "card.updated.attachment.added",
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
toTitle: originalFilenameHeader,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,4 +134,3 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HiOutlineArrowRight,
|
HiOutlineArrowRight,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
HiOutlineClock,
|
HiOutlineClock,
|
||||||
|
HiOutlinePaperClip,
|
||||||
HiOutlinePencil,
|
HiOutlinePencil,
|
||||||
HiOutlinePlus,
|
HiOutlinePlus,
|
||||||
HiOutlineTag,
|
HiOutlineTag,
|
||||||
@@ -31,6 +32,16 @@ import Comment from "./Comment";
|
|||||||
type ActivityType =
|
type ActivityType =
|
||||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||||
|
|
||||||
|
type ActivityWithMergedLabels =
|
||||||
|
GetCardActivitiesOutput["activities"][number] & {
|
||||||
|
mergedLabels?: string[];
|
||||||
|
attachment?: {
|
||||||
|
publicId: string;
|
||||||
|
filename: string;
|
||||||
|
originalFilename: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
const truncate = (value: string | null, maxLength = 50) => {
|
const truncate = (value: string | null, maxLength = 50) => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
@@ -57,6 +68,7 @@ const getActivityText = ({
|
|||||||
toDueDate,
|
toDueDate,
|
||||||
dateLocale,
|
dateLocale,
|
||||||
mergedLabels,
|
mergedLabels,
|
||||||
|
attachmentName,
|
||||||
}: {
|
}: {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
toTitle: string | null;
|
toTitle: string | null;
|
||||||
@@ -71,6 +83,7 @@ const getActivityText = ({
|
|||||||
toDueDate?: Date | null;
|
toDueDate?: Date | null;
|
||||||
dateLocale: DateFnsLocale;
|
dateLocale: DateFnsLocale;
|
||||||
mergedLabels?: string[];
|
mergedLabels?: string[];
|
||||||
|
attachmentName?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||||
@@ -124,6 +137,8 @@ const getActivityText = ({
|
|||||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||||
|
"card.updated.attachment.added": t`added an attachment`,
|
||||||
|
"card.updated.attachment.removed": t`removed an attachment`,
|
||||||
"card.updated.dueDate.added": t`set the due date`,
|
"card.updated.dueDate.added": t`set the due date`,
|
||||||
"card.updated.dueDate.updated": t`updated the due date`,
|
"card.updated.dueDate.updated": t`updated the due date`,
|
||||||
"card.updated.dueDate.removed": t`removed the due date`,
|
"card.updated.dueDate.removed": t`removed the due date`,
|
||||||
@@ -256,6 +271,27 @@ const getActivityText = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "card.updated.attachment.added") {
|
||||||
|
const filename = attachmentName ?? toTitle;
|
||||||
|
if (!filename) return baseText;
|
||||||
|
return (
|
||||||
|
<Trans>
|
||||||
|
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "card.updated.attachment.removed") {
|
||||||
|
const filename = attachmentName ?? fromTitle;
|
||||||
|
if (!filename) return baseText;
|
||||||
|
return (
|
||||||
|
<Trans>
|
||||||
|
removed an attachment{" "}
|
||||||
|
<TextHighlight>{truncate(filename)}</TextHighlight>
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||||
const showYear = !isSameYear(toDueDate, new Date());
|
const showYear = !isSameYear(toDueDate, new Date());
|
||||||
const formattedDate = format(
|
const formattedDate = format(
|
||||||
@@ -308,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
|||||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||||
|
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||||
|
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||||
@@ -341,7 +379,7 @@ const ActivityList = ({
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isViewOnly?: boolean;
|
isViewOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { dateLocale, locale } = useLocalisation();
|
const { dateLocale } = useLocalisation();
|
||||||
const { data: sessionData } = authClient.useSession();
|
const { data: sessionData } = authClient.useSession();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [allActivities, setAllActivities] = useState<
|
const [allActivities, setAllActivities] = useState<
|
||||||
@@ -391,25 +429,21 @@ const ActivityList = ({
|
|||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextPage) {
|
const existingIds = new Set(
|
||||||
const existingIds = new Set(
|
currentActivities.map((a) => a.publicId),
|
||||||
currentActivities.map((a) => a.publicId),
|
);
|
||||||
);
|
const newActivities = nextPage.activities.filter(
|
||||||
const newActivities = nextPage.activities.filter(
|
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
);
|
||||||
);
|
currentActivities = [...currentActivities, ...newActivities];
|
||||||
currentActivities = [...currentActivities, ...newActivities];
|
currentHasMore = nextPage.hasMore;
|
||||||
currentHasMore = nextPage.hasMore;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAllActivities(currentActivities);
|
setAllActivities(currentActivities);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAllRemaining();
|
void fetchAllRemaining();
|
||||||
} else {
|
} else {
|
||||||
setAllActivities(firstPageData.activities);
|
setAllActivities(firstPageData.activities);
|
||||||
setHasMore(firstPageData.hasMore);
|
setHasMore(firstPageData.hasMore);
|
||||||
@@ -436,17 +470,15 @@ const ActivityList = ({
|
|||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextPage) {
|
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
const newActivities = nextPage.activities.filter(
|
||||||
const newActivities = nextPage.activities.filter(
|
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
);
|
||||||
);
|
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
setHasMore(nextPage.hasMore);
|
||||||
setHasMore(nextPage.hasMore);
|
|
||||||
|
|
||||||
if (!nextPage.hasMore) {
|
if (!nextPage.hasMore) {
|
||||||
isFullyExpandedRef.current = true;
|
isFullyExpandedRef.current = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
@@ -473,7 +505,10 @@ const ActivityList = ({
|
|||||||
fromDueDate: activity.fromDueDate ?? null,
|
fromDueDate: activity.fromDueDate ?? null,
|
||||||
toDueDate: activity.toDueDate ?? null,
|
toDueDate: activity.toDueDate ?? null,
|
||||||
dateLocale: dateLocale,
|
dateLocale: dateLocale,
|
||||||
mergedLabels: (activity as any).mergedLabels,
|
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
|
||||||
|
attachmentName:
|
||||||
|
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
|
||||||
|
null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activity.type === "card.updated.comment.added")
|
if (activity.type === "card.updated.comment.added")
|
||||||
|
|||||||
@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Failed to create attachment",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await cardActivityRepo.create(ctx.db, {
|
await cardActivityRepo.create(ctx.db, {
|
||||||
type: "card.updated.attachment.added",
|
type: "card.updated.attachment.added",
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
toTitle: input.originalFilename,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
await cardActivityRepo.create(ctx.db, {
|
await cardActivityRepo.create(ctx.db, {
|
||||||
type: "card.updated.attachment.removed",
|
type: "card.updated.attachment.removed",
|
||||||
cardId: attachment.cardId,
|
cardId: attachment.cardId,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
fromTitle: attachment.originalFilename,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "card_activity" ADD COLUMN "attachmentId" bigint;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1770500457005,
|
"when": 1770500457005,
|
||||||
"tag": "20260207214056_AddNotificationsTable",
|
"tag": "20260207214056_AddNotificationsTable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770521594167,
|
||||||
|
"tag": "20260208033314_AddAttachmentToActivity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ export const create = async (
|
|||||||
fromDueDate?: Date;
|
fromDueDate?: Date;
|
||||||
toDueDate?: Date;
|
toDueDate?: Date;
|
||||||
sourceBoardId?: number;
|
sourceBoardId?: number;
|
||||||
|
attachmentId?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -58,6 +59,7 @@ export const create = async (
|
|||||||
fromDueDate: activityInput.fromDueDate,
|
fromDueDate: activityInput.fromDueDate,
|
||||||
toDueDate: activityInput.toDueDate,
|
toDueDate: activityInput.toDueDate,
|
||||||
sourceBoardId: activityInput.sourceBoardId,
|
sourceBoardId: activityInput.sourceBoardId,
|
||||||
|
attachmentId: activityInput.attachmentId,
|
||||||
})
|
})
|
||||||
.returning({ id: cardActivities.id });
|
.returning({ id: cardActivities.id });
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ export const bulkCreate = async (
|
|||||||
fromDueDate?: Date;
|
fromDueDate?: Date;
|
||||||
toDueDate?: Date;
|
toDueDate?: Date;
|
||||||
sourceBoardId?: number;
|
sourceBoardId?: number;
|
||||||
|
attachmentId?: number;
|
||||||
}[],
|
}[],
|
||||||
) => {
|
) => {
|
||||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||||
@@ -191,6 +194,13 @@ export const getPaginatedActivities = async (
|
|||||||
deletedAt: true,
|
deletedAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attachment: {
|
||||||
|
columns: {
|
||||||
|
publicId: true,
|
||||||
|
filename: true,
|
||||||
|
originalFilename: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: asc(cardActivities.createdAt), // required for merging and pagination
|
orderBy: asc(cardActivities.createdAt), // required for merging and pagination
|
||||||
limit: limit + 1, // fetch one extra to check if there are more
|
limit: limit + 1, // fetch one extra to check if there are more
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ export const cardActivities = pgTable("card_activity", {
|
|||||||
() => boards.id,
|
() => boards.id,
|
||||||
{ onDelete: "set null" },
|
{ onDelete: "set null" },
|
||||||
),
|
),
|
||||||
|
attachmentId: bigint("attachmentId", { mode: "number" }).references(
|
||||||
|
() => cardAttachments.id,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||||
@@ -190,6 +194,11 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
|||||||
references: [comments.id],
|
references: [comments.id],
|
||||||
relationName: "cardActivitiesComment",
|
relationName: "cardActivitiesComment",
|
||||||
}),
|
}),
|
||||||
|
attachment: one(cardAttachments, {
|
||||||
|
fields: [cardActivities.attachmentId],
|
||||||
|
references: [cardAttachments.id],
|
||||||
|
relationName: "cardActivitiesAttachment",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const cardsToLabels = pgTable(
|
export const cardsToLabels = pgTable(
|
||||||
|
|||||||
Reference in New Issue
Block a user