Type fixes and PIT history pagination

This commit is contained in:
carlosmonastyrski
2025-05-21 23:43:41 -03:00
parent f06004370d
commit 2b948a18f3
16 changed files with 383 additions and 162 deletions
+3 -2
View File
@@ -84,9 +84,10 @@ const getZodDefaultValue = (type: unknown, value: string | number | boolean | Ob
} }
}; };
const bigIntegerColumns = { const bigIntegerColumns: Record<string, string[]> = {
"folder_commits": ["commitId"] "folder_commits": ["commitId"]
} };
const main = async () => { const main = async () => {
const tables = ( const tables = (
+62 -27
View File
@@ -10,8 +10,8 @@ import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { booleanSchema } from "@app/server/routes/sanitizedSchemas"; import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType, AuthMode } from "@app/services/auth/auth-type";
import { ChangeType, ResourceChange } from "@app/services/folder-commit/folder-commit-service"; import { ChangeType } from "@app/services/folder-commit/folder-commit-service";
import { commitChangesResponseSchema } from "@app/services/folder-commit/folder-commit-types"; import { commitChangesResponseSchema } from "@app/services/folder-commit/folder-commit-types";
const commitHistoryItemSchema = z.object({ const commitHistoryItemSchema = z.object({
@@ -98,22 +98,34 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
querystring: z.object({ querystring: z.object({
environment: z.string().trim(), environment: z.string().trim(),
path: z.string().trim().default("/").transform(removeTrailingSlash), path: z.string().trim().default("/").transform(removeTrailingSlash),
workspaceId: z.string().trim() workspaceId: z.string().trim(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().trim().optional(),
sort: z.enum(["asc", "desc"]).default("desc")
}), }),
response: { response: {
200: commitHistoryItemSchema.array() 200: z.object({
commits: commitHistoryItemSchema.array(),
total: z.number(),
hasMore: z.boolean()
})
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const commits = await server.services.folderCommit.getCommitsForFolder({ const result = await server.services.folderCommit.getCommitsForFolder({
actor: req.permission?.type, actor: req.permission?.type,
actorId: req.permission?.id, actorId: req.permission?.id,
actorOrgId: req.permission?.orgId, actorOrgId: req.permission?.orgId,
actorAuthMethod: req.permission?.authMethod, actorAuthMethod: req.permission?.authMethod,
projectId: req.query.workspaceId, projectId: req.query.workspaceId,
environment: req.query.environment, environment: req.query.environment,
path: req.query.path path: req.query.path,
offset: req.query.offset,
limit: req.query.limit,
search: req.query.search,
sort: req.query.sort
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -124,30 +136,47 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
metadata: { metadata: {
environment: req.query.environment, environment: req.query.environment,
path: req.query.path, path: req.query.path,
commitCount: commits.length.toString() commitCount: result.commits.length.toString(),
offset: req.query.offset.toString(),
limit: req.query.limit.toString(),
search: req.query.search,
sort: req.query.sort
} }
} }
}); });
return commits.map((commit) => ({ return {
...commit, commits: result.commits.map((commit) => ({
commitId: commit.commitId.toString() ...commit,
})); commitId: commit.commitId.toString()
})),
total: result.total,
hasMore: result.hasMore
};
} }
}); });
const getChangeVersions = async ( const getChangeVersions = async (
change: ResourceChange, change: {
secretVersion?: string;
secretId?: string;
id?: string;
isUpdate?: boolean;
changeType?: string;
},
previousVersion: string, previousVersion: string,
actorId: string, actorId: string,
actor: string, actor: ActorType,
actorOrgId: string, actorOrgId: string,
actorAuthMethod: string, actorAuthMethod: ActorAuthMethod,
folderId: string folderId: string
) => { ) => {
if (change.secretVersion) { if (change.secretVersion) {
const currentVersion = change.secretVersion || "1"; const currentVersion = change.secretVersion || "1";
const secretId = change.secretId ? change.secretId : change.id; const secretId = change.secretId ? change.secretId : change.id;
if (!secretId) {
return;
}
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const versions = await server.services.secret.getSecretVersionsV2ByIds({ const versions = await server.services.secret.getSecretVersionsV2ByIds({
actorId, actorId,
@@ -176,7 +205,15 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
} }
}; };
const getFolderVersions = async (change: ResourceChange, fromVersion: string, folderId: string) => { const getFolderVersions = async (
change: {
folderVersion?: string;
isUpdate?: boolean;
changeType?: string;
},
fromVersion: string,
folderId: string
) => {
const currentVersion = change.folderVersion || "1"; const currentVersion = change.folderVersion || "1";
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const versions = await server.services.folder.getFolderVersionsByIds({ const versions = await server.services.folder.getFolderVersionsByIds({
@@ -218,20 +255,18 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
commitId: req.params.commitId commitId: req.params.commitId
}); });
for (const change of changes.changes) { for (const change of changes.changes) {
if (change.secretVersionId) { if (change.secretVersionId && change.secretVersion) {
change.objectType = "secret";
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
change.versions = await getChangeVersions( change.versions = await getChangeVersions(
change, change,
(Number.parseInt(change.secretVersion, 10) - 1).toString(), (Number.parseInt(change.secretVersion, 10) - 1).toString(),
req.permission?.id, req.permission.id,
req.permission?.type, req.permission.type,
req.permission?.orgId, req.permission.orgId,
req.permission?.authMethod, req.permission.authMethod,
change.folderId change.folderId
); );
} else if (change.folderVersionId && change.folderChangeId) { } else if (change.folderVersionId && change.folderChangeId && change.folderVersion) {
change.objectType = "folder";
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
change.versions = await getFolderVersions( change.versions = await getFolderVersions(
change, change,
@@ -340,10 +375,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
change.versions = await getChangeVersions( change.versions = await getChangeVersions(
change, change,
change.fromVersion || "1", change.fromVersion || "1",
req.permission?.id, req.permission.id,
req.permission?.type, req.permission.type,
req.permission?.orgId, req.permission.orgId,
req.permission?.authMethod, req.permission.authMethod,
diff.folderId diff.folderId
); );
} }
@@ -2927,6 +2927,10 @@ interface GetProjectPitCommitsEvent {
commitCount: string; commitCount: string;
environment: string; environment: string;
path: string; path: string;
offset: string;
limit: string;
search?: string;
sort: string;
}; };
} }
@@ -101,7 +101,7 @@ export const folderCheckpointDALFactory = (db: TDbClient) => {
`${TableName.FolderCommit}.id` `${TableName.FolderCommit}.id`
) )
.where(`${TableName.FolderCommit}.folderId`, "=", folderId) .where(`${TableName.FolderCommit}.folderId`, "=", folderId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId) .where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderCheckpoint)) .select(selectAllTableCols(TableName.FolderCheckpoint))
.select( .select(
db.ref("actorMetadata").withSchema(TableName.FolderCommit), db.ref("actorMetadata").withSchema(TableName.FolderCommit),
@@ -14,7 +14,7 @@ import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitChangesDALFactory = ReturnType<typeof folderCommitChangesDALFactory>; export type TFolderCommitChangesDALFactory = ReturnType<typeof folderCommitChangesDALFactory>;
type CommitChangeWithCommitInfo = TFolderCommitChanges & { export type CommitChangeWithCommitInfo = TFolderCommitChanges & {
actorMetadata: unknown; actorMetadata: unknown;
actorType: string; actorType: string;
message?: string | null; message?: string | null;
@@ -25,6 +25,7 @@ type CommitChangeWithCommitInfo = TFolderCommitChanges & {
secretVersion?: string; secretVersion?: string;
secretId?: string; secretId?: string;
folderChangeId?: string; folderChangeId?: string;
objectType?: string;
versions?: { versions?: {
secretKey?: string; secretKey?: string;
secretComment?: string; secretComment?: string;
@@ -9,7 +9,7 @@ import {
TSecretFolderVersions, TSecretFolderVersions,
TSecretVersionsV2 TSecretVersionsV2
} from "@app/db/schemas"; } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError, NotFoundError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
export type TFolderCommitDALFactory = ReturnType<typeof folderCommitDALFactory>; export type TFolderCommitDALFactory = ReturnType<typeof folderCommitDALFactory>;
@@ -209,8 +209,8 @@ export const folderCommitDALFactory = (db: TDbClient) => {
const commits = await (tx || db.replicaNode())(TableName.FolderCommit) const commits = await (tx || db.replicaNode())(TableName.FolderCommit)
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter({ folderId }, TableName.FolderCommit)) .where(buildFindFilter({ folderId }, TableName.FolderCommit))
.andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber) .andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber.toString())
.andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber) .andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber.toString())
.select(selectAllTableCols(TableName.FolderCommit)) .select(selectAllTableCols(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "asc"); .orderBy(`${TableName.FolderCommit}.commitId`, "asc");
@@ -391,12 +391,89 @@ export const folderCommitDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.FolderCommit)) .select(selectAllTableCols(TableName.FolderCommit))
.orderBy("commitId", "desc") .orderBy("commitId", "desc")
.first(); .first();
if (!doc) {
throw new NotFoundError({
message: `Folder commit not found for ID ${id}`
});
}
return doc; return doc;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindById" }); throw new DatabaseError({ error, name: "FindById" });
} }
}; };
const findByFolderIdPaginated = async (
folderId: string,
options: {
offset?: number;
limit?: number;
search?: string;
sort?: "asc" | "desc";
} = {},
tx?: Knex
): Promise<{
commits: TFolderCommits[];
total: number;
hasMore: boolean;
}> => {
try {
const { offset = 0, limit = 20, search, sort = "desc" } = options;
const trx = tx || db.replicaNode();
// Build base query
let baseQuery = trx(TableName.FolderCommit).where({ folderId });
// Add search functionality
if (search) {
baseQuery = baseQuery.where((qb) => {
void qb.whereILike("message", `%${search}%`);
});
}
// Get total count
const totalResult = await baseQuery.clone().count("*", { as: "count" }).first();
const total = Number(totalResult?.count || 0);
// Get paginated commits
const folderCommits = await baseQuery.select("*").orderBy("createdAt", sort).limit(limit).offset(offset);
if (folderCommits.length === 0) {
return { commits: [], total, hasMore: false };
}
// Get all commit IDs for changes
const commitIds = folderCommits.map((commit) => commit.id);
// Get all related changes
const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*");
const changesMap = changes.reduce(
(acc, change) => {
const { folderCommitId } = change;
if (!acc[folderCommitId]) acc[folderCommitId] = [];
acc[folderCommitId].push(change);
return acc;
},
{} as Record<string, TFolderCommitChanges[]>
);
const commitsWithChanges = folderCommits.map((commit) => ({
...commit,
changes: changesMap[commit.id] || []
}));
const hasMore = offset + limit < total;
return {
commits: commitsWithChanges,
total,
hasMore
};
} catch (error) {
throw new DatabaseError({ error, name: "FindByFolderIdPaginated" });
}
};
return { return {
...restOfOrm, ...restOfOrm,
findByFolderId, findByFolderId,
@@ -411,6 +488,7 @@ export const folderCommitDALFactory = (db: TDbClient) => {
findLatestCommitByFolderIds, findLatestCommitByFolderIds,
findAllFolderCommitsAfter, findAllFolderCommitsAfter,
findPreviousCommitTo, findPreviousCommitTo,
findById findById,
findByFolderIdPaginated
}; };
}; };
@@ -107,7 +107,7 @@ describe("folderCommitServiceFactory", () => {
deleteById: vi.fn().mockResolvedValue({}), deleteById: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue({}),
updateById: vi.fn().mockResolvedValue({}), updateById: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue([]), find: vi.fn().mockResolvedValue({}), // Changed from [] to {} to match Object.values() expectation
findByIdsWithLatestVersion: vi.fn().mockResolvedValue({}) findByIdsWithLatestVersion: vi.fn().mockResolvedValue({})
}; };
@@ -260,6 +260,17 @@ describe("folderCommitServiceFactory", () => {
mockFolderDAL.findByParentId.mockResolvedValue([]); mockFolderDAL.findByParentId.mockResolvedValue([]);
mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]); mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]);
// Mock folderVersionDAL.find to return an object with folder version data
mockFolderVersionDAL.find.mockResolvedValue({
"folder-version-1": {
id: "folder-version-1",
folderId: "sub-folder-id",
envId: "env-id",
name: "Test Folder",
version: 1
}
});
const data = { const data = {
actor: { actor: {
type: ActorType.IDENTITY, type: ActorType.IDENTITY,
@@ -449,6 +460,12 @@ describe("folderCommitServiceFactory", () => {
const actorType = ActorType.USER; const actorType = ActorType.USER;
const projectId = "project-id"; const projectId = "project-id";
// Mock the transaction to properly handle the error
mockFolderCommitDAL.transaction.mockImplementation(async (callback) => {
return await callback({} as Knex);
});
// Mock findById to return null inside the transaction
mockFolderCommitDAL.findById.mockResolvedValue(null); mockFolderCommitDAL.findById.mockResolvedValue(null);
// Act & Assert // Act & Assert
@@ -501,8 +518,8 @@ describe("folderCommitServiceFactory", () => {
const actorType = ActorType.USER; const actorType = ActorType.USER;
const differences = [ const differences = [
{ type: "secret", id: "secret-1", versionId: "v1", changeType: ChangeType.CREATE, commitId: 1 }, { type: "secret", id: "secret-1", versionId: "v1", changeType: ChangeType.CREATE, commitId: BigInt(1) },
{ type: "folder", id: "folder-1", versionId: "v2", changeType: ChangeType.UPDATE, commitId: 1 } { type: "folder", id: "folder-1", versionId: "v2", changeType: ChangeType.UPDATE, commitId: BigInt(1) }
]; ];
const secretVersions = { const secretVersions = {
@@ -76,6 +76,7 @@ export type ResourceChange = {
commitId: bigint; commitId: bigint;
createdAt?: Date; createdAt?: Date;
parentId?: string; parentId?: string;
isUpdate?: boolean;
secretKey?: string; secretKey?: string;
secretVersion?: string; secretVersion?: string;
secretId?: string; secretId?: string;
@@ -997,7 +998,11 @@ export const folderCommitServiceFactory = ({
actorOrgId, actorOrgId,
projectId, projectId,
environment, environment,
path path,
offset = 0,
limit = 20,
search,
sort = "desc"
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
@@ -1006,6 +1011,10 @@ export const folderCommitServiceFactory = ({
projectId: string; projectId: string;
environment: string; environment: string;
path: string; path: string;
offset: number;
limit: number;
search?: string;
sort: "asc" | "desc";
}) => { }) => {
await checkProjectPermission({ await checkProjectPermission({
actor, actor,
@@ -1020,7 +1029,12 @@ export const folderCommitServiceFactory = ({
message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}` message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}`
}); });
} }
const folderCommits = await folderCommitDAL.findByFolderId(folder.id); const folderCommits = await folderCommitDAL.findByFolderIdPaginated(folder.id, {
offset,
limit,
search,
sort
});
return folderCommits; return folderCommits;
}; };
@@ -1516,6 +1530,9 @@ export const folderCommitServiceFactory = ({
projectId: string; projectId: string;
message?: string; message?: string;
}) => { }) => {
if (!permissionService) {
throw new Error("Permission service not initialized");
}
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
actorId, actorId,
@@ -1551,8 +1568,11 @@ export const folderCommitServiceFactory = ({
} }
// Sort commits by commitId (which appears to be numeric) // Sort commits by commitId (which appears to be numeric)
const sortedCommits = allCommits.sort((a, b) => a.commitId - b.commitId); const sortedCommits = allCommits.sort((a, b) => {
if (a.commitId < b.commitId) return -1;
if (a.commitId > b.commitId) return 1;
return 0;
});
// Find the index of the commit to revert // Find the index of the commit to revert
const commitIndex = sortedCommits.findIndex((c) => c.id === commitId); const commitIndex = sortedCommits.findIndex((c) => c.id === commitId);
if (commitIndex === -1) { if (commitIndex === -1) {
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
const secretVersionSchema = z.object({ const secretVersionSchema = z.object({
secretKey: z.string(), secretKey: z.string().optional().nullable(),
secretComment: z.string().optional().nullable(), secretComment: z.string().optional().nullable(),
skipMultilineEncoding: z.boolean().optional().nullable(), skipMultilineEncoding: z.boolean().optional().nullable(),
secretReminderRepeatDays: z.number().optional().nullable(), secretReminderRepeatDays: z.number().optional().nullable(),
@@ -13,7 +13,7 @@ const secretVersionSchema = z.object({
}); });
const folderVersionSchema = z.object({ const folderVersionSchema = z.object({
name: z.string() name: z.string().optional().nullable()
}); });
const baseChangeSchema = z.object({ const baseChangeSchema = z.object({
@@ -37,46 +37,37 @@ const baseChangeSchema = z.object({
folderId: z.string().optional() folderId: z.string().optional()
}); });
const secretChangeSchema = baseChangeSchema.extend({ const commitChangeSchema = baseChangeSchema.extend({
objectType: z.literal("secret"), secretVersionId: z.string().optional().nullable(),
secretVersionId: z.string(), folderVersionId: z.string().optional().nullable(),
folderVersionId: z.null(), folderName: z.string().optional().nullable(),
folderName: z.null(), folderChangeId: z.string().optional().nullable(),
folderChangeId: z.null(), secretKey: z.string().optional().nullable(),
secretKey: z.string(), secretVersion: z.union([z.string(), z.number()]).optional().nullable(),
secretVersion: z.union([z.string(), z.number()]), secretId: z.string().optional().nullable(),
secretId: z.string(), folderVersion: z.union([z.string(), z.number()]).optional().nullable(),
versions: z.array(secretVersionSchema).optional() versions: z.array(z.union([secretVersionSchema, folderVersionSchema])).optional()
}); });
const folderChangeSchema = baseChangeSchema.extend({
objectType: z.literal("folder"),
secretVersionId: z.null(),
folderVersionId: z.string(),
folderName: z.string(),
folderChangeId: z.string(),
folderVersion: z.union([z.string(), z.number()]),
secretKey: z.null(),
secretId: z.null(),
versions: z.array(folderVersionSchema).optional()
});
const commitChangeSchema = z.discriminatedUnion("objectType", [secretChangeSchema, folderChangeSchema]);
const commitSchema = z.object({ const commitSchema = z.object({
id: z.string(), id: z.string(),
commitId: z.string(), commitId: z.string(),
actorMetadata: z.object({ actorMetadata: z
id: z.string(), .union([
name: z.string() z.object({
}), id: z.string().optional(),
name: z.string().optional()
}),
z.unknown()
])
.optional(),
actorType: z.string(), actorType: z.string(),
message: z.string().nullable(), message: z.string().nullable().optional(),
folderId: z.string(), folderId: z.string(),
envId: z.string(), envId: z.string(),
createdAt: z.union([z.string(), z.date()]), createdAt: z.union([z.string(), z.date()]),
updatedAt: z.union([z.string(), z.date()]), updatedAt: z.union([z.string(), z.date()]),
changes: z.array(commitChangeSchema) changes: z.array(commitChangeSchema).optional()
}); });
export const commitChangesResponseSchema = z.object({ export const commitChangesResponseSchema = z.object({
@@ -41,7 +41,7 @@ export const folderTreeCheckpointDALFactory = (db: TDbClient) => {
) )
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(`${TableName.FolderCommit}.envId`, "=", envId) .where(`${TableName.FolderCommit}.envId`, "=", envId)
.where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId) .andWhere(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString())
.select(selectAllTableCols(TableName.FolderTreeCheckpoint)) .select(selectAllTableCols(TableName.FolderTreeCheckpoint))
.select(db.ref("commitId").withSchema(TableName.FolderCommit)) .select(db.ref("commitId").withSchema(TableName.FolderCommit))
.orderBy(`${TableName.FolderCommit}.commitId`, "desc") .orderBy(`${TableName.FolderCommit}.commitId`, "desc")
@@ -145,7 +145,9 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
// Create lookup map for max versions // Create lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce<Record<string, number>>((acc, item) => { const maxVersionMap = maxVersionsQuery.reduce<Record<string, number>>((acc, item) => {
acc[item.folderId] = item.maxVersion; if (item.maxVersion) {
acc[item.folderId] = item.maxVersion;
}
return acc; return acc;
}, {}); }, {});
@@ -160,6 +162,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => {
try { try {
if (!folderIds.length && (!versionIds || !versionIds.length)) return {}; if (!folderIds.length && (!versionIds || !versionIds.length)) return {};
// Run both queries in parallel
const [latestVersions, specificVersionsWithLatest] = await Promise.all([ const [latestVersions, specificVersionsWithLatest] = await Promise.all([
folderIds.length ? getLatestFolderVersions(folderIds, tx) : [], folderIds.length ? getLatestFolderVersions(folderIds, tx) : [],
versionIds?.length ? getSpecificFolderVersionsWithLatest(versionIds, tx) : [] versionIds?.length ? getSpecificFolderVersionsWithLatest(versionIds, tx) : []
@@ -324,7 +324,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.whereIn("secretId", specificSecretIds) .whereIn("secretId", specificSecretIds)
.groupBy("secretId") .groupBy("secretId")
.select("secretId") .select("secretId")
.max("version as maxVersion"); .max("version", { as: "maxVersion" });
// Create a lookup map for max versions // Create a lookup map for max versions
const maxVersionMap = maxVersionsQuery.reduce( const maxVersionMap = maxVersionsQuery.reduce(
@@ -68,13 +68,29 @@ const fetchFolderCommitsCount = async ({
const fetchFolderCommitHistory = async ( const fetchFolderCommitHistory = async (
workspaceId: string, workspaceId: string,
environment: string, environment: string,
directory: string directory: string,
): Promise<CommitHistoryItem[]> => { offset: number = 0,
const res = await apiRequest.get<CommitHistoryItem[]>("/api/v1/pit/commits", { limit: number = 20,
search?: string,
sort: "asc" | "desc" = "desc"
): Promise<{
commits: CommitHistoryItem[];
total: number;
hasMore: boolean;
}> => {
const res = await apiRequest.get<{
commits: CommitHistoryItem[];
total: number;
hasMore: boolean;
}>("/api/v1/pit/commits", {
params: { params: {
environment, environment,
path: directory, path: directory,
workspaceId workspaceId,
offset,
limit,
search,
sort
} }
}); });
return res.data; return res.data;
@@ -225,15 +241,30 @@ export const useGetFolderCommitsCount = ({
export const useGetFolderCommitHistory = ({ export const useGetFolderCommitHistory = ({
workspaceId, workspaceId,
environment, environment,
directory directory,
offset = 0,
limit = 20,
search,
sort = "desc"
}: { }: {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
directory: string; directory: string;
offset?: number;
limit?: number;
search?: string;
sort?: "asc" | "desc";
}) => { }) => {
return useQuery({ return useQuery({
queryKey: commitKeys.history({ workspaceId, environment, directory }), queryKey: [
queryFn: () => fetchFolderCommitHistory(workspaceId, environment, directory), commitKeys.history({ workspaceId, environment, directory }),
offset,
limit,
search,
sort
],
queryFn: () =>
fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort),
enabled: Boolean(workspaceId && environment) enabled: Boolean(workspaceId && environment)
}); });
}; };
@@ -11,7 +11,8 @@ export enum ApprovalStatus {
export enum CommitType { export enum CommitType {
DELETE = "delete", DELETE = "delete",
UPDATE = "update", UPDATE = "update",
CREATE = "create" CREATE = "create",
ADD = "add"
} }
export type TSecretApprovalSecChangeData = { export type TSecretApprovalSecChangeData = {
@@ -22,6 +22,7 @@ import {
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { CommitWithChanges } from "@app/hooks/api/folderCommits"; import { CommitWithChanges } from "@app/hooks/api/folderCommits";
import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries"; import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries";
import { CommitType } from "@app/hooks/api/types";
import { SecretVersionDiffView } from "../SecretVersionDiffView"; import { SecretVersionDiffView } from "../SecretVersionDiffView";
import { MergedItem } from "./types"; import { MergedItem } from "./types";
@@ -147,9 +148,9 @@ export const CommitDetailsTab = ({
const commitChanges = parsedCommitDetails.changes?.changes || []; const commitChanges = parsedCommitDetails.changes?.changes || [];
// Separate changes by type // Separate changes by type
const addedChanges = commitChanges.filter((c) => c.changeType === "add" && !c.isUpdate); const addedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && !c.isUpdate);
const updatedChanges = commitChanges.filter((c) => c.changeType === "add" && c.isUpdate); const updatedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && c.isUpdate);
const deletedChanges = commitChanges.filter((c) => c.changeType === "delete"); const deletedChanges = commitChanges.filter((c) => c.changeType === CommitType.DELETE);
// Create merged item list from changes only // Create merged item list from changes only
const changedItems: MergedItem[] = []; const changedItems: MergedItem[] = [];
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
faArrowDownWideShort, faArrowDownWideShort,
faArrowUpWideShort, faArrowUpWideShort,
@@ -144,41 +144,68 @@ export const CommitHistoryTab = ({
secretPath: string; secretPath: string;
}) => { }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [visibleCommits, setVisibleCommits] = useState(10); const [offset, setOffset] = useState(0);
const [allCommits, setAllCommits] = useState<Commit[]>([]);
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
const limit = 5;
const { data: commits, isLoading } = useGetFolderCommitHistory({ // Debounce search term
useEffect(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [searchTerm]);
const {
data: response,
isLoading,
isFetching
} = useGetFolderCommitHistory({
workspaceId: projectId, workspaceId: projectId,
environment, environment,
directory: secretPath directory: secretPath,
offset,
limit,
search: debouncedSearchTerm,
sort: sortDirection
}); });
const filteredCommits = useMemo(() => { const commits = response?.commits || [];
if (!commits?.length) return []; const hasMore = response?.hasMore || false;
return commits.filter( // Reset accumulated commits when search or sort changes
(commit) => useEffect(() => {
commit.id?.includes(searchTerm) || setAllCommits([]);
commit.message?.toLowerCase().includes(searchTerm.toLowerCase()) || setOffset(0);
commit.actorMetadata?.name?.toLowerCase().includes(searchTerm.toLowerCase()) }, [debouncedSearchTerm, sortDirection]);
);
}, [commits, searchTerm]);
const sortedCommits = useMemo(() => { // Accumulate commits instead of replacing them
if (!filteredCommits?.length) return []; useEffect(() => {
if (commits.length > 0) {
if (offset === 0) {
// First load or after search/sort change - replace all commits
setAllCommits(commits);
} else {
// Subsequent loads - append new commits
setAllCommits((prev) => [...prev, ...commits]);
}
}
}, [commits, offset]);
return [...filteredCommits].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return sortDirection === "desc" ? dateB - dateA : dateA - dateB;
});
}, [filteredCommits, sortDirection]);
const displayedCommits = useMemo(() => {
return sortedCommits.slice(0, visibleCommits);
}, [sortedCommits, visibleCommits]);
const groupedCommits = useMemo(() => { const groupedCommits = useMemo(() => {
return displayedCommits.reduce( return allCommits.reduce(
(acc, commit) => { (acc, commit) => {
const date = format(new Date(commit.createdAt), "MMM d, yyyy"); const date = format(new Date(commit.createdAt), "MMM d, yyyy");
if (!acc[date]) { if (!acc[date]) {
@@ -189,25 +216,21 @@ export const CommitHistoryTab = ({
}, },
{} as Record<string, Commit[]> {} as Record<string, Commit[]>
); );
}, [displayedCommits]); }, [allCommits]);
const handleSort = useCallback(() => { const handleSort = useCallback(() => {
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"));
}, []); }, []);
const loadMoreCommits = useCallback(() => { const handleSearch = useCallback((value: string) => {
setVisibleCommits((prev) => prev + 10); setSearchTerm(value);
}, []); }, []);
if (isLoading) { const loadMoreCommits = useCallback(() => {
return ( if (hasMore && !isFetching) {
<div className="flex h-64 items-center justify-center"> setOffset((prev) => prev + limit);
<Spinner size="lg" aria-label="Loading commits" /> }
</div> }, [hasMore, isFetching, limit]);
);
}
const hasMoreCommits = sortedCommits.length > visibleCommits;
return ( return (
<div className="w-full"> <div className="w-full">
@@ -217,7 +240,8 @@ export const CommitHistoryTab = ({
<Input <Input
placeholder="Search commits..." placeholder="Search commits..."
className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20" className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20"
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
value={searchTerm}
aria-label="Search commits" aria-label="Search commits"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400"> <div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
@@ -239,43 +263,57 @@ export const CommitHistoryTab = ({
</div> </div>
</div> </div>
<div className="space-y-8"> {isLoading && offset === 0 ? (
{Object.keys(groupedCommits).length > 0 ? ( <div className="flex h-64 items-center justify-center">
<> <Spinner size="lg" aria-label="Loading commits" />
{Object.entries(groupedCommits).map(([date, dateCommits]) => ( </div>
<DateGroup ) : (
key={date} <div className="space-y-8">
date={date} {Object.keys(groupedCommits).length > 0 ? (
commits={dateCommits} <>
onSelectCommit={onSelectCommit} {Object.entries(groupedCommits).map(([date, dateCommits]) => (
<DateGroup
key={date}
date={date}
commits={dateCommits}
onSelectCommit={onSelectCommit}
/>
))}
</>
) : (
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/> />
))} <p>No matching commits found. Try a different search term.</p>
</> </div>
) : ( )}
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/>
<p>No matching commits found. Try a different search term.</p>
</div>
)}
{hasMoreCommits && ( {hasMore && (
<div className="flex justify-center pb-2"> <div className="flex justify-center pb-2">
<Button <Button
variant="outline_bg" variant="outline_bg"
size="md" size="md"
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500" className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={loadMoreCommits} onClick={loadMoreCommits}
aria-label="Load more commits" disabled={isFetching}
> aria-label="Load more commits"
Load more commits >
</Button> {isFetching ? (
</div> <>
)} <Spinner size="sm" className="mr-2" />
</div> Loading...
</>
) : (
"Load more commits"
)}
</Button>
</div>
)}
</div>
)}
</div> </div>
); );
}; };