mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-13 09:35:39 +00:00
Compare commits
6 Commits
commit-ui-
...
feat/remin
Author | SHA1 | Date | |
---|---|---|---|
0e680e366b | |||
0af00ce82d | |||
3153450dc5 | |||
50ba2e543c | |||
e2559f10bc | |||
0efc314f33 |
@ -24,6 +24,7 @@ export const mockQueue = (): TQueueServiceFactory => {
|
||||
events[name] = event;
|
||||
},
|
||||
getRepeatableJobs: async () => [],
|
||||
getDelayedJobs: async () => [],
|
||||
clearQueue: async () => {},
|
||||
stopJobById: async () => {},
|
||||
stopJobByIdPg: async () => {},
|
||||
|
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@ -93,6 +93,7 @@ import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env
|
||||
import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service";
|
||||
import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
|
||||
import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service";
|
||||
import { TReminderServiceFactory } from "@app/services/reminder/reminder-types";
|
||||
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
|
||||
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
|
||||
@ -285,6 +286,7 @@ declare module "fastify" {
|
||||
secretScanningV2: TSecretScanningV2ServiceFactory;
|
||||
internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory;
|
||||
pkiTemplate: TPkiTemplatesServiceFactory;
|
||||
reminder: TReminderServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
12
backend/src/@types/knex.d.ts
vendored
12
backend/src/@types/knex.d.ts
vendored
@ -504,6 +504,12 @@ import {
|
||||
TProjectMicrosoftTeamsConfigsInsert,
|
||||
TProjectMicrosoftTeamsConfigsUpdate
|
||||
} from "@app/db/schemas/project-microsoft-teams-configs";
|
||||
import { TReminders, TRemindersInsert, TRemindersUpdate } from "@app/db/schemas/reminders";
|
||||
import {
|
||||
TRemindersRecipients,
|
||||
TRemindersRecipientsInsert,
|
||||
TRemindersRecipientsUpdate
|
||||
} from "@app/db/schemas/reminders-recipients";
|
||||
import {
|
||||
TSecretReminderRecipients,
|
||||
TSecretReminderRecipientsInsert,
|
||||
@ -1211,5 +1217,11 @@ declare module "knex/types/tables" {
|
||||
TSecretScanningConfigsInsert,
|
||||
TSecretScanningConfigsUpdate
|
||||
>;
|
||||
[TableName.Reminder]: KnexOriginal.CompositeTableType<TReminders, TRemindersInsert, TRemindersUpdate>;
|
||||
[TableName.ReminderRecipient]: KnexOriginal.CompositeTableType<
|
||||
TRemindersRecipients,
|
||||
TRemindersRecipientsInsert,
|
||||
TRemindersRecipientsUpdate
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.Reminder))) {
|
||||
await knex.schema.createTable(TableName.Reminder, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("secretId").nullable();
|
||||
t.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE");
|
||||
t.string("message", 1024).nullable();
|
||||
t.integer("repeatDays").checkPositive().nullable();
|
||||
t.timestamp("nextReminderDate").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
t.index("secretId");
|
||||
t.unique("secretId");
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.ReminderRecipient))) {
|
||||
await knex.schema.createTable(TableName.ReminderRecipient, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("reminderId").notNullable();
|
||||
t.foreign("reminderId").references("id").inTable(TableName.Reminder).onDelete("CASCADE");
|
||||
t.uuid("userId").notNullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
|
||||
t.timestamps(true, true, true);
|
||||
t.index("reminderId");
|
||||
t.index("userId");
|
||||
t.unique(["reminderId", "userId"]);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.Reminder);
|
||||
await createOnUpdateTrigger(knex, TableName.ReminderRecipient);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await dropOnUpdateTrigger(knex, TableName.Reminder);
|
||||
await dropOnUpdateTrigger(knex, TableName.ReminderRecipient);
|
||||
await knex.schema.dropTableIfExists(TableName.ReminderRecipient);
|
||||
await knex.schema.dropTableIfExists(TableName.Reminder);
|
||||
}
|
@ -172,7 +172,10 @@ export enum TableName {
|
||||
SecretScanningResource = "secret_scanning_resources",
|
||||
SecretScanningScan = "secret_scanning_scans",
|
||||
SecretScanningFinding = "secret_scanning_findings",
|
||||
SecretScanningConfig = "secret_scanning_configs"
|
||||
SecretScanningConfig = "secret_scanning_configs",
|
||||
// reminders
|
||||
Reminder = "reminders",
|
||||
ReminderRecipient = "reminders_recipients"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||
|
20
backend/src/db/schemas/reminders-recipients.ts
Normal file
20
backend/src/db/schemas/reminders-recipients.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const RemindersRecipientsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
reminderId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TRemindersRecipients = z.infer<typeof RemindersRecipientsSchema>;
|
||||
export type TRemindersRecipientsInsert = Omit<z.input<typeof RemindersRecipientsSchema>, TImmutableDBKeys>;
|
||||
export type TRemindersRecipientsUpdate = Partial<Omit<z.input<typeof RemindersRecipientsSchema>, TImmutableDBKeys>>;
|
22
backend/src/db/schemas/reminders.ts
Normal file
22
backend/src/db/schemas/reminders.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const RemindersSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
secretId: z.string().uuid().nullable().optional(),
|
||||
message: z.string().nullable().optional(),
|
||||
repeatDays: z.number().nullable().optional(),
|
||||
nextReminderDate: z.date(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TReminders = z.infer<typeof RemindersSchema>;
|
||||
export type TRemindersInsert = Omit<z.input<typeof RemindersSchema>, TImmutableDBKeys>;
|
||||
export type TRemindersUpdate = Partial<Omit<z.input<typeof RemindersSchema>, TImmutableDBKeys>>;
|
@ -467,7 +467,11 @@ export enum EventType {
|
||||
|
||||
CREATE_PROJECT = "create-project",
|
||||
UPDATE_PROJECT = "update-project",
|
||||
DELETE_PROJECT = "delete-project"
|
||||
DELETE_PROJECT = "delete-project",
|
||||
|
||||
CREATE_SECRET_REMINDER = "create-secret-reminder",
|
||||
GET_SECRET_REMINDER = "get-secret-reminder",
|
||||
DELETE_SECRET_REMINDER = "delete-secret-reminder"
|
||||
}
|
||||
|
||||
export const filterableSecretEvents: EventType[] = [
|
||||
@ -3312,6 +3316,31 @@ interface SecretScanningConfigUpdateEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretReminderCreateEvent {
|
||||
type: EventType.CREATE_SECRET_REMINDER;
|
||||
metadata: {
|
||||
secretId: string;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: string | null;
|
||||
recipients?: string[] | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretReminderGetEvent {
|
||||
type: EventType.GET_SECRET_REMINDER;
|
||||
metadata: {
|
||||
secretId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretReminderDeleteEvent {
|
||||
type: EventType.DELETE_SECRET_REMINDER;
|
||||
metadata: {
|
||||
secretId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretScanningConfigReadEvent {
|
||||
type: EventType.SECRET_SCANNING_CONFIG_GET;
|
||||
metadata?: Record<string, never>; // not needed, based off projectId
|
||||
@ -3674,4 +3703,7 @@ export type Event =
|
||||
| OrgUpdateEvent
|
||||
| ProjectCreateEvent
|
||||
| ProjectUpdateEvent
|
||||
| ProjectDeleteEvent;
|
||||
| ProjectDeleteEvent
|
||||
| SecretReminderCreateEvent
|
||||
| SecretReminderGetEvent
|
||||
| SecretReminderDeleteEvent;
|
||||
|
@ -63,7 +63,9 @@ export enum QueueName {
|
||||
FolderTreeCheckpoint = "folder-tree-checkpoint",
|
||||
InvalidateCache = "invalidate-cache",
|
||||
SecretScanningV2 = "secret-scanning-v2",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events"
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events",
|
||||
DailyReminders = "daily-reminders",
|
||||
SecretReminderMigration = "secret-reminder-migration"
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@ -103,7 +105,9 @@ export enum QueueJobs {
|
||||
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events"
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events",
|
||||
DailyReminders = "daily-reminders",
|
||||
SecretReminderMigration = "secret-reminder-migration"
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@ -290,6 +294,14 @@ export type TQueueJobTypes = {
|
||||
caType: CaType;
|
||||
};
|
||||
};
|
||||
[QueueName.DailyReminders]: {
|
||||
name: QueueJobs.DailyReminders;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.SecretReminderMigration]: {
|
||||
name: QueueJobs.SecretReminderMigration;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PkiSubscriber]: {
|
||||
name: QueueJobs.PkiSubscriberDailyAutoRenewal;
|
||||
payload: undefined;
|
||||
@ -389,6 +401,11 @@ export type TQueueServiceFactory = {
|
||||
startOffset?: number,
|
||||
endOffset?: number
|
||||
) => Promise<{ key: string; name: string; id: string | null }[]>;
|
||||
getDelayedJobs: (
|
||||
name: QueueName,
|
||||
startOffset?: number,
|
||||
endOffset?: number
|
||||
) => Promise<{ delay: number; timestamp: number; repeatJobKey?: string; data?: unknown }[]>;
|
||||
};
|
||||
|
||||
export const queueServiceFactory = (
|
||||
@ -535,6 +552,13 @@ export const queueServiceFactory = (
|
||||
return q.getRepeatableJobs(startOffset, endOffset);
|
||||
};
|
||||
|
||||
const getDelayedJobs: TQueueServiceFactory["getDelayedJobs"] = (name, startOffset, endOffset) => {
|
||||
const q = queueContainer[name];
|
||||
if (!q) throw new Error(`Queue '${name}' not initialized`);
|
||||
|
||||
return q.getDelayed(startOffset, endOffset);
|
||||
};
|
||||
|
||||
const stopRepeatableJobByJobId: TQueueServiceFactory["stopRepeatableJobByJobId"] = async (name, jobId) => {
|
||||
const q = queueContainer[name];
|
||||
const job = await q.getJob(jobId);
|
||||
@ -581,6 +605,7 @@ export const queueServiceFactory = (
|
||||
stopJobById,
|
||||
stopJobByIdPg,
|
||||
getRepeatableJobs,
|
||||
getDelayedJobs,
|
||||
startPg,
|
||||
queuePg,
|
||||
schedulePg
|
||||
|
@ -245,6 +245,10 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi
|
||||
import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal";
|
||||
import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal";
|
||||
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
|
||||
import { reminderDALFactory } from "@app/services/reminder/reminder-dal";
|
||||
import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue";
|
||||
import { reminderServiceFactory } from "@app/services/reminder/reminder-service";
|
||||
import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal";
|
||||
import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue";
|
||||
import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
|
||||
import { secretDALFactory } from "@app/services/secret/secret-dal";
|
||||
@ -369,6 +373,9 @@ export const registerRoutes = async (
|
||||
const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db);
|
||||
const secretVersionTagV2BridgeDAL = secretVersionV2TagBridgeDALFactory(db);
|
||||
|
||||
const reminderDAL = reminderDALFactory(db);
|
||||
const reminderRecipientDAL = reminderRecipientDALFactory(db);
|
||||
|
||||
const integrationDAL = integrationDALFactory(db);
|
||||
const integrationAuthDAL = integrationAuthDALFactory(db);
|
||||
const webhookDAL = webhookDALFactory(db);
|
||||
@ -732,9 +739,17 @@ export const registerRoutes = async (
|
||||
|
||||
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL });
|
||||
|
||||
const reminderService = reminderServiceFactory({
|
||||
reminderDAL,
|
||||
reminderRecipientDAL,
|
||||
smtpService,
|
||||
projectMembershipDAL,
|
||||
permissionService,
|
||||
secretV2BridgeDAL
|
||||
});
|
||||
|
||||
const orgService = orgServiceFactory({
|
||||
userAliasDAL,
|
||||
queueService,
|
||||
identityMetadataDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
@ -760,7 +775,8 @@ export const registerRoutes = async (
|
||||
orgBotDAL,
|
||||
oidcConfigDAL,
|
||||
loginService,
|
||||
projectBotService
|
||||
projectBotService,
|
||||
reminderService
|
||||
});
|
||||
const signupService = authSignupServiceFactory({
|
||||
tokenService,
|
||||
@ -1058,7 +1074,6 @@ export const registerRoutes = async (
|
||||
secretImportDAL,
|
||||
projectEnvDAL,
|
||||
webhookDAL,
|
||||
orgDAL,
|
||||
auditLogService,
|
||||
userDAL,
|
||||
projectMembershipDAL,
|
||||
@ -1080,11 +1095,11 @@ export const registerRoutes = async (
|
||||
secretApprovalRequestDAL,
|
||||
projectKeyDAL,
|
||||
projectUserMembershipRoleDAL,
|
||||
secretReminderRecipientsDAL,
|
||||
orgService,
|
||||
resourceMetadataDAL,
|
||||
folderCommitService,
|
||||
secretSyncQueue
|
||||
secretSyncQueue,
|
||||
reminderService
|
||||
});
|
||||
|
||||
const projectService = projectServiceFactory({
|
||||
@ -1093,7 +1108,6 @@ export const registerRoutes = async (
|
||||
projectSshConfigDAL,
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
projectQueue: projectQueueService,
|
||||
projectBotService,
|
||||
identityProjectDAL,
|
||||
@ -1130,7 +1144,8 @@ export const registerRoutes = async (
|
||||
microsoftTeamsIntegrationDAL,
|
||||
projectTemplateService,
|
||||
groupProjectDAL,
|
||||
smtpService
|
||||
smtpService,
|
||||
reminderService
|
||||
});
|
||||
|
||||
const projectEnvService = projectEnvServiceFactory({
|
||||
@ -1229,6 +1244,7 @@ export const registerRoutes = async (
|
||||
kmsService,
|
||||
snapshotService,
|
||||
resourceMetadataDAL,
|
||||
reminderService,
|
||||
keyStore
|
||||
});
|
||||
|
||||
@ -1282,7 +1298,8 @@ export const registerRoutes = async (
|
||||
secretApprovalRequestSecretDAL,
|
||||
secretV2BridgeService,
|
||||
secretApprovalRequestService,
|
||||
licenseService
|
||||
licenseService,
|
||||
reminderService
|
||||
});
|
||||
|
||||
const secretSharingService = secretSharingServiceFactory({
|
||||
@ -1609,7 +1626,6 @@ export const registerRoutes = async (
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
secretFolderVersionDAL: folderVersionDAL,
|
||||
snapshotDAL,
|
||||
identityAccessTokenDAL,
|
||||
@ -1620,6 +1636,13 @@ export const registerRoutes = async (
|
||||
orgService
|
||||
});
|
||||
|
||||
const dailyReminderQueueService = dailyReminderQueueServiceFactory({
|
||||
reminderService,
|
||||
queueService,
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretReminderRecipientsDAL
|
||||
});
|
||||
|
||||
const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({
|
||||
queueService,
|
||||
pkiAlertService
|
||||
@ -1913,6 +1936,8 @@ export const registerRoutes = async (
|
||||
await telemetryQueue.startTelemetryCheck();
|
||||
await telemetryQueue.startAggregatedEventsJob();
|
||||
await dailyResourceCleanUp.startCleanUp();
|
||||
await dailyReminderQueueService.startDailyRemindersJob();
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await kmsService.startService();
|
||||
@ -2023,7 +2048,8 @@ export const registerRoutes = async (
|
||||
assumePrivileges: assumePrivilegeService,
|
||||
githubOrgSync: githubOrgSyncConfigService,
|
||||
folderCommit: folderCommitService,
|
||||
secretScanningV2: secretScanningV2Service
|
||||
secretScanningV2: secretScanningV2Service,
|
||||
reminder: reminderService
|
||||
});
|
||||
|
||||
const cronJobs: CronJob[] = [];
|
||||
|
@ -42,6 +42,7 @@ import { registerProjectEnvRouter } from "./project-env-router";
|
||||
import { registerProjectKeyRouter } from "./project-key-router";
|
||||
import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { SECRET_REMINDER_REGISTER_ROUTER_MAP } from "./reminder-routers";
|
||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||
import { registerSecretImportRouter } from "./secret-import-router";
|
||||
import { registerSecretRequestsRouter } from "./secret-requests-router";
|
||||
@ -172,4 +173,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
{ prefix: "/secret-syncs" }
|
||||
);
|
||||
|
||||
await server.register(
|
||||
async (reminderRouter) => {
|
||||
// register service specific reminder endpoints (reminders/secret)
|
||||
for await (const [reminderType, router] of Object.entries(SECRET_REMINDER_REGISTER_ROUTER_MAP)) {
|
||||
await reminderRouter.register(router, { prefix: `/${reminderType}` });
|
||||
}
|
||||
},
|
||||
{ prefix: "/reminders" }
|
||||
);
|
||||
};
|
||||
|
8
backend/src/server/routes/v1/reminder-routers/index.ts
Normal file
8
backend/src/server/routes/v1/reminder-routers/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ReminderType } from "@app/services/reminder/reminder-enums";
|
||||
|
||||
import { registerSecretReminderRouter } from "./secret-reminder-router";
|
||||
|
||||
export const SECRET_REMINDER_REGISTER_ROUTER_MAP: Record<ReminderType, (server: FastifyZodProvider) => Promise<void>> =
|
||||
{
|
||||
[ReminderType.SECRETS]: registerSecretReminderRouter
|
||||
};
|
@ -0,0 +1,169 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { RemindersSchema } from "@app/db/schemas/reminders";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerSecretReminderRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/:secretId",
|
||||
method: "POST",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
secretId: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
message: z.string().trim().max(1024).optional(),
|
||||
repeatDays: z.number().min(1).nullable().optional(),
|
||||
nextReminderDate: z.string().datetime().nullable().optional(),
|
||||
recipients: z.string().array().optional()
|
||||
})
|
||||
.refine((data) => {
|
||||
return data.repeatDays || data.nextReminderDate;
|
||||
}, "At least one of repeatDays or nextReminderDate is required"),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
await server.services.reminder.createReminder({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
reminder: {
|
||||
secretId: req.params.secretId,
|
||||
message: req.body.message,
|
||||
repeatDays: req.body.repeatDays,
|
||||
nextReminderDate: req.body.nextReminderDate,
|
||||
recipients: req.body.recipients
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.CREATE_SECRET_REMINDER,
|
||||
metadata: {
|
||||
secretId: req.params.secretId,
|
||||
message: req.body.message,
|
||||
repeatDays: req.body.repeatDays,
|
||||
nextReminderDate: req.body.nextReminderDate,
|
||||
recipients: req.body.recipients
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { message: "Successfully created reminder" };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:secretId",
|
||||
method: "GET",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
secretId: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
reminder: RemindersSchema.extend({
|
||||
recipients: z.string().array().optional()
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const reminder = await server.services.reminder.getReminder({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
secretId: req.params.secretId,
|
||||
projectId: req.query.projectId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_REMINDER,
|
||||
metadata: {
|
||||
secretId: req.params.secretId
|
||||
}
|
||||
}
|
||||
});
|
||||
return { reminder };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:secretId",
|
||||
method: "DELETE",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
secretId: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
await server.services.reminder.deleteReminder({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
secretId: req.params.secretId,
|
||||
projectId: req.query.projectId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.DELETE_SECRET_REMINDER,
|
||||
metadata: {
|
||||
secretId: req.params.secretId
|
||||
}
|
||||
}
|
||||
});
|
||||
return { message: "Successfully deleted reminder" };
|
||||
}
|
||||
});
|
||||
};
|
@ -49,7 +49,7 @@ import { groupBy } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { isDisposableEmail } from "@app/lib/validator";
|
||||
import { QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { QueueName } from "@app/queue";
|
||||
import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||
@ -67,6 +67,7 @@ import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
@ -134,8 +135,8 @@ type TOrgServiceFactoryDep = {
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "updateById">;
|
||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "create">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
loginService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
||||
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
|
||||
};
|
||||
|
||||
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
||||
@ -167,8 +168,8 @@ export const orgServiceFactory = ({
|
||||
projectUserMembershipRoleDAL,
|
||||
identityMetadataDAL,
|
||||
projectBotService,
|
||||
queueService,
|
||||
loginService
|
||||
loginService,
|
||||
reminderService
|
||||
}: TOrgServiceFactoryDep) => {
|
||||
/*
|
||||
* Get organization details by the organization id
|
||||
@ -610,7 +611,7 @@ export const orgServiceFactory = ({
|
||||
await fnDeleteProjectSecretReminders(project.id, {
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
reminderService,
|
||||
projectBotService,
|
||||
folderDAL
|
||||
});
|
||||
|
@ -32,7 +32,6 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { TQueueServiceFactory } from "@app/queue";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
@ -60,6 +59,7 @@ import { TProjectMembershipDALFactory } from "../project-membership/project-memb
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||
import { getPredefinedRoles } from "../project-role/project-role-fns";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
|
||||
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
@ -162,7 +162,6 @@ type TProjectServiceFactoryDep = {
|
||||
permissionService: TPermissionServiceFactory;
|
||||
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "invalidateGetPlan">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
||||
@ -179,6 +178,7 @@ type TProjectServiceFactoryDep = {
|
||||
| "createCipherPairWithDataKey"
|
||||
>;
|
||||
projectTemplateService: TProjectTemplateServiceFactory;
|
||||
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
|
||||
};
|
||||
|
||||
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
||||
@ -191,7 +191,6 @@ export const projectServiceFactory = ({
|
||||
projectQueue,
|
||||
projectKeyDAL,
|
||||
permissionService,
|
||||
queueService,
|
||||
projectBotService,
|
||||
orgDAL,
|
||||
userDAL,
|
||||
@ -226,7 +225,8 @@ export const projectServiceFactory = ({
|
||||
microsoftTeamsIntegrationDAL,
|
||||
projectTemplateService,
|
||||
groupProjectDAL,
|
||||
smtpService
|
||||
smtpService,
|
||||
reminderService
|
||||
}: TProjectServiceFactoryDep) => {
|
||||
/*
|
||||
* Create workspace. Make user the admin
|
||||
@ -555,7 +555,7 @@ export const projectServiceFactory = ({
|
||||
await fnDeleteProjectSecretReminders(project.id, {
|
||||
secretDAL,
|
||||
secretV2BridgeDAL,
|
||||
queueService,
|
||||
reminderService,
|
||||
projectBotService,
|
||||
folderDAL
|
||||
});
|
||||
|
@ -0,0 +1,11 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TReminderRecipientDALFactory = ReturnType<typeof reminderRecipientDALFactory>;
|
||||
|
||||
export const reminderRecipientDALFactory = (db: TDbClient) => {
|
||||
const reminderRecipientOrm = ormify(db, TableName.ReminderRecipient);
|
||||
|
||||
return { ...reminderRecipientOrm };
|
||||
};
|
133
backend/src/services/reminder/reminder-dal.ts
Normal file
133
backend/src/services/reminder/reminder-dal.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import {
|
||||
TableName,
|
||||
TOrganizations,
|
||||
TProjectEnvironments,
|
||||
TProjects,
|
||||
TSecretFolders,
|
||||
TSecretsV2,
|
||||
TUsers
|
||||
} from "@app/db/schemas";
|
||||
import { RemindersSchema } from "@app/db/schemas/reminders";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
|
||||
export type TReminderDALFactory = ReturnType<typeof reminderDALFactory>;
|
||||
|
||||
export const reminderDALFactory = (db: TDbClient) => {
|
||||
const reminderOrm = ormify(db, TableName.Reminder);
|
||||
|
||||
const getTodayDateRange = () => {
|
||||
const today = new Date();
|
||||
const year = today.getUTCFullYear();
|
||||
const month = today.getUTCMonth();
|
||||
const date = today.getUTCDate();
|
||||
|
||||
// Start of day: 00:00:00.000 UTC
|
||||
const startOfDay = new Date(Date.UTC(year, month, date, 0, 0, 0, 0));
|
||||
|
||||
// End of day: 23:59:59.999 UTC
|
||||
const endOfDay = new Date(Date.UTC(year, month, date, 23, 59, 59, 999));
|
||||
|
||||
return {
|
||||
startOfDay,
|
||||
endOfDay
|
||||
};
|
||||
};
|
||||
|
||||
const findSecretDailyReminders = async (tx?: Knex) => {
|
||||
const { startOfDay, endOfDay } = getTodayDateRange();
|
||||
|
||||
const rawReminders = await (tx || db)(TableName.Reminder)
|
||||
.whereBetween("nextReminderDate", [startOfDay, endOfDay])
|
||||
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
|
||||
.leftJoin<TUsers>(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin<TSecretsV2>(TableName.SecretV2, `${TableName.Reminder}.secretId`, `${TableName.SecretV2}.id`)
|
||||
.leftJoin<TSecretFolders>(
|
||||
TableName.SecretFolder,
|
||||
`${TableName.SecretV2}.folderId`,
|
||||
`${TableName.SecretFolder}.id`
|
||||
)
|
||||
.leftJoin<TProjectEnvironments>(
|
||||
TableName.Environment,
|
||||
`${TableName.SecretFolder}.envId`,
|
||||
`${TableName.Environment}.id`
|
||||
)
|
||||
.leftJoin<TProjects>(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`)
|
||||
.leftJoin<TOrganizations>(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
|
||||
.select(selectAllTableCols(TableName.Reminder))
|
||||
.select(db.ref("email").withSchema(TableName.Users))
|
||||
.select(db.ref("name").withSchema(TableName.Project).as("projectName"))
|
||||
.select(db.ref("id").withSchema(TableName.Project).as("projectId"))
|
||||
.select(db.ref("name").withSchema(TableName.Organization).as("organizationName"));
|
||||
|
||||
const reminders = sqlNestRelationships({
|
||||
data: rawReminders,
|
||||
key: "id",
|
||||
parentMapper: (el) => ({
|
||||
_id: el.id,
|
||||
...RemindersSchema.parse(el),
|
||||
projectName: el.projectName,
|
||||
projectId: el.projectId,
|
||||
organizationName: el.organizationName
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "email",
|
||||
label: "recipients" as const,
|
||||
mapper: ({ email }) => ({
|
||||
email
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
return reminders;
|
||||
};
|
||||
|
||||
const findUpcomingReminders = async (daysAhead: number = 7, tx?: Knex) => {
|
||||
const { startOfDay } = getTodayDateRange();
|
||||
const futureDate = new Date(startOfDay);
|
||||
futureDate.setDate(futureDate.getDate() + daysAhead);
|
||||
|
||||
const reminders = await (tx || db)(TableName.Reminder)
|
||||
.where("nextReminderDate", ">=", startOfDay)
|
||||
.where("nextReminderDate", "<=", futureDate)
|
||||
.orderBy("nextReminderDate", "asc")
|
||||
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
|
||||
.select(selectAllTableCols(TableName.Reminder))
|
||||
.select(db.ref("userId").withSchema(TableName.ReminderRecipient));
|
||||
return reminders;
|
||||
};
|
||||
|
||||
const findSecretReminder = async (secretId: string, tx?: Knex) => {
|
||||
const rawReminders = await (tx || db)(TableName.Reminder)
|
||||
.where(`${TableName.Reminder}.secretId`, secretId)
|
||||
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
|
||||
.select(selectAllTableCols(TableName.Reminder))
|
||||
.select(db.ref("userId").withSchema(TableName.ReminderRecipient));
|
||||
const reminders = sqlNestRelationships({
|
||||
data: rawReminders,
|
||||
key: "id",
|
||||
parentMapper: (el) => ({
|
||||
_id: el.id,
|
||||
...RemindersSchema.parse(el)
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "recipients" as const,
|
||||
mapper: ({ userId }) => userId
|
||||
}
|
||||
]
|
||||
});
|
||||
return reminders[0] || null;
|
||||
};
|
||||
|
||||
return {
|
||||
...reminderOrm,
|
||||
findSecretDailyReminders,
|
||||
findUpcomingReminders,
|
||||
findSecretReminder
|
||||
};
|
||||
};
|
3
backend/src/services/reminder/reminder-enums.ts
Normal file
3
backend/src/services/reminder/reminder-enums.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum ReminderType {
|
||||
SECRETS = "secrets"
|
||||
}
|
196
backend/src/services/reminder/reminder-queue.ts
Normal file
196
backend/src/services/reminder/reminder-queue.ts
Normal file
@ -0,0 +1,196 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import RE2 from "re2";
|
||||
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { TReminderServiceFactory } from "./reminder-types";
|
||||
|
||||
type TDailyReminderQueueServiceFactoryDep = {
|
||||
reminderService: TReminderServiceFactory;
|
||||
queueService: TQueueServiceFactory;
|
||||
secretDAL: Pick<TSecretV2BridgeDALFactory, "transaction" | "findSecretsWithReminderRecipients">;
|
||||
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
|
||||
};
|
||||
|
||||
export type TDailyReminderQueueServiceFactory = ReturnType<typeof dailyReminderQueueServiceFactory>;
|
||||
|
||||
const uuidRegex = new RE2(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
|
||||
export const dailyReminderQueueServiceFactory = ({
|
||||
reminderService,
|
||||
queueService,
|
||||
secretDAL,
|
||||
secretReminderRecipientsDAL
|
||||
}: TDailyReminderQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.DailyReminders, async () => {
|
||||
logger.info(`${QueueName.DailyReminders}: queue task started`);
|
||||
await reminderService.sendDailyReminders();
|
||||
logger.info(`${QueueName.DailyReminders}: queue task completed`);
|
||||
});
|
||||
|
||||
queueService.start(QueueName.SecretReminderMigration, async () => {
|
||||
const REMINDER_PRUNE_BATCH_SIZE = 5_000;
|
||||
const MAX_RETRY_ON_FAILURE = 3;
|
||||
let numberOfRetryOnFailure = 0;
|
||||
let deletedReminderCount = 0;
|
||||
|
||||
logger.info(`${QueueName.SecretReminderMigration}: queue task started`);
|
||||
try {
|
||||
const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder);
|
||||
const delayedJobs = await queueService.getDelayedJobs(QueueName.SecretReminder);
|
||||
logger.info(`${QueueName.SecretReminderMigration}: found ${repeatableJobs.length} secret reminder jobs`);
|
||||
|
||||
const reminderJobs = repeatableJobs
|
||||
.map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key }))
|
||||
.filter(Boolean);
|
||||
const reminderDelayedJobs = delayedJobs.reduce((map, job) => {
|
||||
const match = uuidRegex.exec(job.repeatJobKey || "");
|
||||
if (match) {
|
||||
map.set(match[0], {
|
||||
timestamp: job.timestamp,
|
||||
delay: job.delay,
|
||||
data: job.data
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, { timestamp: number; delay: number; data: unknown }>());
|
||||
if (reminderJobs.length === 0) {
|
||||
logger.info(`${QueueName.SecretReminderMigration}: no reminder jobs found`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) {
|
||||
try {
|
||||
const batch = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE);
|
||||
const batchIds = batch.map((job) => job.secretId);
|
||||
|
||||
// Find existing secrets with pagination
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const secrets = await secretDAL.findSecretsWithReminderRecipients(batchIds, REMINDER_PRUNE_BATCH_SIZE);
|
||||
const secretsWithReminder = secrets.filter((secret) => secret.reminderRepeatDays);
|
||||
|
||||
const foundSecretIds = new Set(secretsWithReminder.map((secret) => secret.id));
|
||||
|
||||
// Find IDs that don't exist in either table
|
||||
const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId));
|
||||
|
||||
// Delete reminders for non-existent secrets
|
||||
for (const secretId of secretIdsNotFound) {
|
||||
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
|
||||
|
||||
if (jobKey) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
|
||||
deletedReminderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const secretId of foundSecretIds) {
|
||||
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
|
||||
|
||||
if (jobKey) {
|
||||
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
|
||||
deletedReminderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await secretDAL.transaction(async (tx) => {
|
||||
await reminderService.batchCreateReminders(
|
||||
secretsWithReminder.map((secret) => {
|
||||
const delayedJob = reminderDelayedJobs.get(secret.id);
|
||||
const projectId = (delayedJob?.data as { projectId?: string })?.projectId;
|
||||
const nextDate = delayedJob ? new Date(delayedJob.timestamp + delayedJob.delay) : undefined;
|
||||
return {
|
||||
secretId: secret.id,
|
||||
message: secret.reminderNote,
|
||||
repeatDays: secret.reminderRepeatDays,
|
||||
nextReminderDate: nextDate,
|
||||
recipients: secret.recipients || [],
|
||||
projectId
|
||||
};
|
||||
}),
|
||||
tx
|
||||
);
|
||||
|
||||
await secretReminderRecipientsDAL.delete({ $in: { secretId: secretsWithReminder.map((s) => s.id) } }, tx);
|
||||
});
|
||||
|
||||
numberOfRetryOnFailure = 0;
|
||||
} catch (error) {
|
||||
numberOfRetryOnFailure += 1;
|
||||
logger.error(error, `Failed to process batch at offset ${offset}`);
|
||||
|
||||
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Retry the current batch
|
||||
offset -= REMINDER_PRUNE_BATCH_SIZE;
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure));
|
||||
}
|
||||
|
||||
// Small delay between batches
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to complete secret reminder pruning");
|
||||
} finally {
|
||||
logger.info(
|
||||
`${QueueName.SecretReminderMigration}: secret reminders completed. Deleted ${deletedReminderCount} reminders`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
||||
const startDailyRemindersJob = async () => {
|
||||
// clear previous job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.DailyReminders,
|
||||
QueueJobs.DailyReminders,
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.DailyReminders // just a job id
|
||||
);
|
||||
|
||||
await queueService.queue(QueueName.DailyReminders, QueueJobs.DailyReminders, undefined, {
|
||||
delay: 5000,
|
||||
jobId: QueueName.DailyReminders,
|
||||
repeat: { pattern: "0 0 * * *", utc: true }
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: remove once all the old reminders in queues are migrated
|
||||
const startSecretReminderMigrationJob = async () => {
|
||||
// clear previous job
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.SecretReminderMigration,
|
||||
QueueJobs.SecretReminderMigration,
|
||||
{ pattern: "0 */1 * * *", utc: true },
|
||||
QueueName.SecretReminderMigration // just a job id
|
||||
);
|
||||
|
||||
await queueService.queue(QueueName.SecretReminderMigration, QueueJobs.SecretReminderMigration, undefined, {
|
||||
delay: 5000,
|
||||
jobId: QueueName.SecretReminderMigration,
|
||||
repeat: { pattern: "0 */1 * * *", utc: true }
|
||||
});
|
||||
};
|
||||
|
||||
queueService.listen(QueueName.DailyReminders, "failed", (_, err) => {
|
||||
logger.error(err, `${QueueName.DailyReminders}: daily reminder processing failed`);
|
||||
});
|
||||
|
||||
queueService.listen(QueueName.SecretReminderMigration, "failed", (_, err) => {
|
||||
logger.error(err, `${QueueName.SecretReminderMigration}: secret reminder migration failed`);
|
||||
});
|
||||
|
||||
return {
|
||||
startDailyRemindersJob,
|
||||
startSecretReminderMigrationJob
|
||||
};
|
||||
};
|
362
backend/src/services/reminder/reminder-service.ts
Normal file
362
backend/src/services/reminder/reminder-service.ts
Normal file
@ -0,0 +1,362 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TReminderRecipientDALFactory } from "../reminder-recipients/reminder-recipient-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TReminderDALFactory } from "./reminder-dal";
|
||||
import { TBatchCreateReminderDTO, TCreateReminderDTO, TReminderServiceFactory } from "./reminder-types";
|
||||
|
||||
type TReminderServiceFactoryDep = {
|
||||
reminderDAL: TReminderDALFactory;
|
||||
reminderRecipientDAL: TReminderRecipientDALFactory;
|
||||
smtpService: TSmtpService;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "invalidateSecretCacheByProjectId">;
|
||||
};
|
||||
|
||||
export const reminderServiceFactory = ({
|
||||
reminderDAL,
|
||||
reminderRecipientDAL,
|
||||
smtpService,
|
||||
projectMembershipDAL,
|
||||
permissionService,
|
||||
secretV2BridgeDAL
|
||||
}: TReminderServiceFactoryDep): TReminderServiceFactory => {
|
||||
const $addDays = (days: number, fromDate: Date = new Date()): Date => {
|
||||
const result = new Date(fromDate);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
const $manageReminderRecipients = async (reminderId: string, newRecipients?: string[] | null): Promise<void> => {
|
||||
if (!newRecipients || newRecipients.length === 0) {
|
||||
// If no recipients provided, remove all existing recipients
|
||||
await reminderRecipientDAL.deleteById(reminderId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove duplicates from input
|
||||
const uniqueRecipients = [...new Set(newRecipients)];
|
||||
|
||||
// Get existing recipients
|
||||
const existingRecipients = await reminderRecipientDAL.find({ reminderId });
|
||||
const existingUserIds = new Set(existingRecipients.map((r) => r.userId));
|
||||
const newUserIds = new Set(uniqueRecipients);
|
||||
|
||||
// Find recipients to add and remove
|
||||
const recipientsToAdd = uniqueRecipients.filter((userId) => !existingUserIds.has(userId));
|
||||
const recipientsToRemove = existingRecipients.filter((r) => !newUserIds.has(r.userId));
|
||||
|
||||
// Perform database operations
|
||||
if (recipientsToRemove.length > 0) {
|
||||
await reminderRecipientDAL.delete({ $in: { id: recipientsToRemove.map((r) => r.id) } });
|
||||
}
|
||||
|
||||
if (recipientsToAdd.length > 0) {
|
||||
await reminderRecipientDAL.insertMany(
|
||||
recipientsToAdd.map((userId) => ({
|
||||
reminderId,
|
||||
userId
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const createReminderInternal: TReminderServiceFactory["createReminderInternal"] = async ({
|
||||
secretId,
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate: nextReminderDateInput,
|
||||
recipients,
|
||||
projectId
|
||||
}: {
|
||||
secretId?: string;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: string | null;
|
||||
recipients?: string[] | null;
|
||||
projectId: string;
|
||||
}) => {
|
||||
if (!secretId) {
|
||||
throw new BadRequestError({ message: "secretId is required" });
|
||||
}
|
||||
let nextReminderDate;
|
||||
if (nextReminderDateInput) {
|
||||
nextReminderDate = new Date(nextReminderDateInput);
|
||||
}
|
||||
|
||||
if (repeatDays && repeatDays > 0) {
|
||||
nextReminderDate = $addDays(repeatDays);
|
||||
}
|
||||
|
||||
if (!nextReminderDate) {
|
||||
throw new BadRequestError({ message: "repeatDays must be a positive number" });
|
||||
}
|
||||
|
||||
const existingReminder = await reminderDAL.findOne({ secretId });
|
||||
let reminderId: string;
|
||||
|
||||
if (existingReminder) {
|
||||
// Update existing reminder
|
||||
await reminderDAL.updateById(existingReminder.id, {
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate
|
||||
});
|
||||
reminderId = existingReminder.id;
|
||||
} else {
|
||||
// Create new reminder
|
||||
const newReminder = await reminderDAL.create({
|
||||
secretId,
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate
|
||||
});
|
||||
reminderId = newReminder.id;
|
||||
}
|
||||
|
||||
// Manage recipients (add/update/delete as needed)
|
||||
await $manageReminderRecipients(reminderId, recipients);
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
return { id: reminderId, created: !existingReminder };
|
||||
};
|
||||
|
||||
const createReminder: TReminderServiceFactory["createReminder"] = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder
|
||||
}: TCreateReminderDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets);
|
||||
|
||||
const response = await createReminderInternal({
|
||||
...reminder,
|
||||
projectId
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
const getReminder: TReminderServiceFactory["getReminder"] = async ({
|
||||
secretId,
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
}: {
|
||||
secretId: string;
|
||||
projectId: string;
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
const reminder = await reminderDAL.findSecretReminder(secretId);
|
||||
return reminder;
|
||||
};
|
||||
|
||||
const sendDailyReminders: TReminderServiceFactory["sendDailyReminders"] = async () => {
|
||||
const remindersToSend = await reminderDAL.findSecretDailyReminders();
|
||||
|
||||
for (const reminder of remindersToSend) {
|
||||
try {
|
||||
await reminderDAL.transaction(async (tx) => {
|
||||
const recipients: string[] = reminder.recipients
|
||||
.map((r) => r.email)
|
||||
.filter((email): email is string => Boolean(email));
|
||||
if (recipients.length === 0) {
|
||||
const members = await projectMembershipDAL.findAllProjectMembers(reminder.projectId);
|
||||
recipients.push(...members.map((m) => m.user.email).filter((email): email is string => Boolean(email)));
|
||||
}
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.SecretReminder,
|
||||
subjectLine: "Infisical secret reminder",
|
||||
recipients,
|
||||
substitutions: {
|
||||
reminderNote: reminder.message || "",
|
||||
projectName: reminder.projectName || "",
|
||||
organizationName: reminder.organizationName || ""
|
||||
}
|
||||
});
|
||||
if (reminder.repeatDays) {
|
||||
await reminderDAL.updateById(reminder.id, { nextReminderDate: $addDays(reminder.repeatDays) }, tx);
|
||||
} else {
|
||||
await reminderDAL.deleteById(reminder.id, tx);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
error,
|
||||
`Failed to send reminder to recipients ${reminder.recipients.map((r) => r.email).join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReminder: TReminderServiceFactory["deleteReminder"] = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
secretId,
|
||||
projectId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
secretId: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets);
|
||||
await reminderDAL.delete({ secretId });
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
};
|
||||
|
||||
const removeReminderRecipients: TReminderServiceFactory["removeReminderRecipients"] = async (
|
||||
secretId: string,
|
||||
projectId: string,
|
||||
tx?: Knex
|
||||
) => {
|
||||
const reminder = await reminderDAL.findOne({ secretId }, tx);
|
||||
if (!reminder) {
|
||||
return;
|
||||
}
|
||||
await reminderRecipientDAL.delete({ reminderId: reminder.id }, tx);
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
};
|
||||
|
||||
const deleteReminderBySecretId: TReminderServiceFactory["deleteReminderBySecretId"] = async (
|
||||
secretId: string,
|
||||
projectId: string,
|
||||
tx?: Knex
|
||||
) => {
|
||||
await reminderDAL.delete({ secretId }, tx);
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
};
|
||||
|
||||
const batchCreateReminders: TReminderServiceFactory["batchCreateReminders"] = async (
|
||||
remindersData: TBatchCreateReminderDTO,
|
||||
tx?: Knex
|
||||
) => {
|
||||
if (!remindersData || remindersData.length === 0) {
|
||||
return { created: 0, reminderIds: [] };
|
||||
}
|
||||
|
||||
const processedReminders = remindersData.map(
|
||||
({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => {
|
||||
let nextReminderDate;
|
||||
if (nextReminderDateInput) {
|
||||
nextReminderDate = new Date(nextReminderDateInput);
|
||||
}
|
||||
|
||||
if (repeatDays && repeatDays > 0 && !nextReminderDate) {
|
||||
nextReminderDate = $addDays(repeatDays);
|
||||
}
|
||||
|
||||
if (!nextReminderDate) {
|
||||
throw new BadRequestError({
|
||||
message: `repeatDays must be a positive number for secretId: ${secretId}`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secretId,
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate,
|
||||
recipients: recipients ? [...new Set(recipients)] : [],
|
||||
projectId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const newReminders = await reminderDAL.insertMany(
|
||||
processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, projectId }) => ({
|
||||
secretId,
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate,
|
||||
projectId
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
const allRecipientInserts: Array<{ reminderId: string; userId: string }> = [];
|
||||
|
||||
newReminders.forEach((reminder, index) => {
|
||||
const { recipients } = processedReminders[index];
|
||||
if (recipients && recipients.length > 0) {
|
||||
recipients.forEach((userId) => {
|
||||
allRecipientInserts.push({
|
||||
reminderId: reminder.id,
|
||||
userId
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (allRecipientInserts.length > 0) {
|
||||
await reminderRecipientDAL.insertMany(allRecipientInserts, tx);
|
||||
}
|
||||
|
||||
const projectIds = new Set(processedReminders.map((r) => r.projectId).filter((id): id is string => Boolean(id)));
|
||||
for (const projectId of projectIds) {
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
}
|
||||
|
||||
return {
|
||||
created: newReminders.length,
|
||||
reminderIds: newReminders.map((r) => r.id)
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createReminder,
|
||||
getReminder,
|
||||
sendDailyReminders,
|
||||
deleteReminder,
|
||||
removeReminderRecipients,
|
||||
deleteReminderBySecretId,
|
||||
batchCreateReminders,
|
||||
createReminderInternal
|
||||
};
|
||||
};
|
116
backend/src/services/reminder/reminder-types.ts
Normal file
116
backend/src/services/reminder/reminder-types.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
export type TReminder = {
|
||||
id: string;
|
||||
secretId?: string | null;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type TCreateReminderDTO = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
reminder: {
|
||||
secretId?: string;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: string | null;
|
||||
recipients?: string[] | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TBatchCreateReminderDTO = {
|
||||
secretId: string;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: string | Date | null;
|
||||
recipients?: string[] | null;
|
||||
projectId?: string;
|
||||
}[];
|
||||
|
||||
export interface TReminderServiceFactory {
|
||||
createReminder: ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder
|
||||
}: TCreateReminderDTO) => Promise<{
|
||||
id: string;
|
||||
created: boolean;
|
||||
}>;
|
||||
|
||||
getReminder: ({
|
||||
secretId,
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
}: {
|
||||
secretId: string;
|
||||
projectId: string;
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
}) => Promise<(TReminder & { recipients: string[] }) | null>;
|
||||
|
||||
sendDailyReminders: () => Promise<void>;
|
||||
|
||||
deleteReminder: ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
secretId,
|
||||
projectId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
secretId: string;
|
||||
projectId: string;
|
||||
}) => Promise<void>;
|
||||
|
||||
removeReminderRecipients: (secretId: string, projectId: string, tx?: Knex) => Promise<void>;
|
||||
|
||||
deleteReminderBySecretId: (secretId: string, projectId: string, tx?: Knex) => Promise<void>;
|
||||
|
||||
batchCreateReminders: (
|
||||
remindersData: TBatchCreateReminderDTO,
|
||||
tx?: Knex
|
||||
) => Promise<{
|
||||
created: number;
|
||||
reminderIds: string[];
|
||||
}>;
|
||||
|
||||
createReminderInternal: ({
|
||||
secretId,
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate,
|
||||
recipients,
|
||||
projectId
|
||||
}: {
|
||||
secretId?: string;
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: string | null;
|
||||
recipients?: string[] | null;
|
||||
projectId: string;
|
||||
}) => Promise<{
|
||||
id: string;
|
||||
created: boolean;
|
||||
}>;
|
||||
}
|
@ -6,7 +6,6 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal";
|
||||
import { TOrgServiceFactory } from "../org/org-service";
|
||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||
import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
|
||||
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
|
||||
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
|
||||
@ -19,7 +18,6 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
|
||||
identityUniversalAuthClientSecretDAL: Pick<TIdentityUaClientSecretDALFactory, "removeExpiredClientSecrets">;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "pruneExcessVersions">;
|
||||
secretVersionV2DAL: Pick<TSecretVersionV2DALFactory, "pruneExcessVersions">;
|
||||
secretDAL: Pick<TSecretDALFactory, "pruneSecretReminders">;
|
||||
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
|
||||
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets" | "pruneExpiredSecretRequests">;
|
||||
@ -36,7 +34,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
snapshotDAL,
|
||||
secretVersionDAL,
|
||||
secretFolderVersionDAL,
|
||||
secretDAL,
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL,
|
||||
secretVersionV2DAL,
|
||||
@ -46,7 +43,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||
await secretDAL.pruneSecretReminders(queueService);
|
||||
await identityAccessTokenDAL.removeExpiredTokens();
|
||||
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
||||
await secretSharingDAL.pruneExpiredSharedSecrets();
|
||||
|
@ -466,6 +466,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
}
|
||||
};
|
||||
|
||||
// This method currently uses too many joins which is not performant, in case we need to add more filters we should consider refactoring this method
|
||||
const findByFolderIds = async (dto: {
|
||||
folderIds: string[];
|
||||
userId?: string;
|
||||
@ -513,18 +514,15 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretReminderRecipients,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretReminderRecipients}.secretId`
|
||||
)
|
||||
.leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
|
||||
.leftJoin(
|
||||
TableName.SecretRotationV2SecretMapping,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretRotationV2SecretMapping}.secretId`
|
||||
)
|
||||
.leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`)
|
||||
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
|
||||
.leftJoin(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`)
|
||||
.where((qb) => {
|
||||
if (filters?.metadataFilter && filters.metadataFilter.length > 0) {
|
||||
filters.metadataFilter.forEach((meta) => {
|
||||
@ -547,7 +545,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
}) as rank`
|
||||
)
|
||||
)
|
||||
.select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId"))
|
||||
.select(db.ref("id").withSchema(TableName.Reminder).as("reminderId"))
|
||||
.select(db.ref("message").withSchema(TableName.Reminder).as("reminderNote"))
|
||||
.select(db.ref("repeatDays").withSchema(TableName.Reminder).as("reminderRepeatDays"))
|
||||
.select(db.ref("nextReminderDate").withSchema(TableName.Reminder).as("nextReminderDate"))
|
||||
.select(db.ref("id").withSchema(TableName.ReminderRecipient).as("reminderRecipientId"))
|
||||
.select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername"))
|
||||
.select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail"))
|
||||
.select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId"))
|
||||
@ -809,6 +811,44 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findSecretsWithReminderRecipients = async (ids: string[], limit: number, tx?: Knex) => {
|
||||
try {
|
||||
// Create a subquery to get limited secret IDs
|
||||
const limitedSecretIds = (tx || db)(TableName.SecretV2)
|
||||
.whereIn(`${TableName.SecretV2}.id`, ids)
|
||||
.limit(limit)
|
||||
.select("id");
|
||||
|
||||
// Join with all recipients for the limited secrets
|
||||
const docs = await (tx || db)(TableName.SecretV2)
|
||||
.whereIn(`${TableName.SecretV2}.id`, limitedSecretIds)
|
||||
.leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`)
|
||||
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
|
||||
.select(selectAllTableCols(TableName.SecretV2))
|
||||
.select(db.ref("userId").withSchema(TableName.ReminderRecipient).as("reminderRecipientUserId"));
|
||||
|
||||
const data = sqlNestRelationships({
|
||||
data: docs,
|
||||
key: "id",
|
||||
parentMapper: (el) => ({
|
||||
_id: el.id,
|
||||
...SecretsV2Schema.parse(el)
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "reminderRecipientUserId",
|
||||
label: "recipients" as const,
|
||||
mapper: ({ reminderRecipientUserId }) => reminderRecipientUserId
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindSecretsWithReminderRecipients" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...secretOrm,
|
||||
update,
|
||||
@ -826,6 +866,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
||||
countByFolderIds,
|
||||
findOne,
|
||||
find,
|
||||
invalidateSecretCacheByProjectId
|
||||
invalidateSecretCacheByProjectId,
|
||||
findSecretsWithReminderRecipients
|
||||
};
|
||||
};
|
||||
|
@ -223,20 +223,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
const actorType = actor?.type || ActorType.PLATFORM;
|
||||
|
||||
const sanitizedInputSecrets = inputSecrets.map(
|
||||
({
|
||||
filter,
|
||||
data: {
|
||||
skipMultilineEncoding,
|
||||
type,
|
||||
key,
|
||||
encryptedValue,
|
||||
userId,
|
||||
encryptedComment,
|
||||
metadata,
|
||||
reminderNote,
|
||||
reminderRepeatDays
|
||||
}
|
||||
}) => ({
|
||||
({ filter, data: { skipMultilineEncoding, type, key, encryptedValue, userId, encryptedComment, metadata } }) => ({
|
||||
filter: { ...filter, folderId },
|
||||
data: {
|
||||
skipMultilineEncoding,
|
||||
@ -245,9 +232,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
userId,
|
||||
encryptedComment,
|
||||
metadata,
|
||||
reminderNote,
|
||||
encryptedValue,
|
||||
reminderRepeatDays
|
||||
encryptedValue
|
||||
}
|
||||
})
|
||||
);
|
||||
@ -263,9 +248,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
encryptedComment,
|
||||
version,
|
||||
metadata,
|
||||
reminderNote,
|
||||
encryptedValue,
|
||||
reminderRepeatDays,
|
||||
id: secretId
|
||||
}) => ({
|
||||
skipMultilineEncoding,
|
||||
@ -275,9 +258,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
encryptedComment,
|
||||
version,
|
||||
metadata: metadata ? JSON.stringify(metadata) : [],
|
||||
reminderNote,
|
||||
encryptedValue,
|
||||
reminderRepeatDays,
|
||||
folderId,
|
||||
secretId,
|
||||
userActorId,
|
||||
@ -395,7 +376,8 @@ export const fnSecretBulkDelete = async ({
|
||||
secretDAL,
|
||||
secretQueueService,
|
||||
folderCommitService,
|
||||
secretVersionDAL
|
||||
secretVersionDAL,
|
||||
projectId
|
||||
}: TFnSecretBulkDelete) => {
|
||||
const deletedSecrets = await secretDAL.deleteMany(
|
||||
inputSecrets.map(({ type, secretKey }) => ({
|
||||
@ -407,11 +389,14 @@ export const fnSecretBulkDelete = async ({
|
||||
tx
|
||||
);
|
||||
|
||||
await Promise.allSettled(
|
||||
await Promise.all(
|
||||
deletedSecrets
|
||||
.filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays))
|
||||
.map(({ id, reminderRepeatDays }) =>
|
||||
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx)
|
||||
secretQueueService.removeSecretReminder(
|
||||
{ secretId: id, repeatDays: reminderRepeatDays as number, projectId },
|
||||
tx
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -32,6 +32,7 @@ import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-serv
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
||||
import { TSecretQueueFactory } from "../secret/secret-queue";
|
||||
import { TGetASecretByIdDTO } from "../secret/secret-types";
|
||||
@ -108,6 +109,7 @@ type TSecretV2BridgeServiceFactoryDep = {
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "setExpiry" | "setItemWithExpiry" | "deleteItem">;
|
||||
reminderService: Pick<TReminderServiceFactory, "createReminder" | "getReminder">;
|
||||
};
|
||||
|
||||
export type TSecretV2BridgeServiceFactory = ReturnType<typeof secretV2BridgeServiceFactory>;
|
||||
@ -132,7 +134,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretApprovalRequestSecretDAL,
|
||||
kmsService,
|
||||
resourceMetadataDAL,
|
||||
keyStore
|
||||
keyStore,
|
||||
reminderService
|
||||
}: TSecretV2BridgeServiceFactoryDep) => {
|
||||
const $validateSecretReferences = async (
|
||||
projectId: string,
|
||||
@ -303,7 +306,6 @@ export const secretV2BridgeServiceFactory = ({
|
||||
{
|
||||
version: 1,
|
||||
type,
|
||||
reminderRepeatDays: inputSecretData.secretReminderRepeatDays,
|
||||
encryptedComment: setKnexStringValue(
|
||||
inputSecretData.secretComment,
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
|
||||
@ -311,7 +313,6 @@ export const secretV2BridgeServiceFactory = ({
|
||||
encryptedValue: inputSecretData.secretValue
|
||||
? secretManagerEncryptor({ plainText: Buffer.from(inputSecretData.secretValue) }).cipherTextBlob
|
||||
: undefined,
|
||||
reminderNote: inputSecretData.secretReminderNote,
|
||||
skipMultilineEncoding: inputSecretData.skipMultilineEncoding,
|
||||
key: secretName,
|
||||
userId: inputSecret.type === SecretType.Personal ? actorId : null,
|
||||
@ -337,6 +338,21 @@ export const secretV2BridgeServiceFactory = ({
|
||||
return createdSecret;
|
||||
});
|
||||
|
||||
if (inputSecret.secretReminderRepeatDays) {
|
||||
await reminderService.createReminder({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder: {
|
||||
secretId: secret.id,
|
||||
message: inputSecret.secretReminderNote,
|
||||
repeatDays: inputSecret.secretReminderRepeatDays
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
if (inputSecret.type === SecretType.Shared) {
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
@ -512,12 +528,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
{
|
||||
filter: { id: secretId },
|
||||
data: {
|
||||
reminderRepeatDays: inputSecret.secretReminderRepeatDays,
|
||||
encryptedComment: setKnexStringValue(
|
||||
inputSecret.secretComment,
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
|
||||
),
|
||||
reminderNote: inputSecret.secretReminderNote,
|
||||
skipMultilineEncoding: inputSecret.skipMultilineEncoding,
|
||||
key: inputSecret.newSecretName || secretName,
|
||||
tags: inputSecret.tagIds,
|
||||
@ -538,19 +552,21 @@ export const secretV2BridgeServiceFactory = ({
|
||||
tx
|
||||
})
|
||||
);
|
||||
await secretQueueService.handleSecretReminder({
|
||||
newSecret: {
|
||||
id: updatedSecret[0].id,
|
||||
...inputSecret
|
||||
},
|
||||
oldSecret: {
|
||||
id: secret.id,
|
||||
secretReminderNote: secret.reminderNote,
|
||||
secretReminderRepeatDays: secret.reminderRepeatDays,
|
||||
secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id)
|
||||
},
|
||||
projectId
|
||||
});
|
||||
if (inputSecret.secretReminderRepeatDays) {
|
||||
await reminderService.createReminder({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder: {
|
||||
secretId: secret.id,
|
||||
message: inputSecret.secretReminderNote,
|
||||
repeatDays: inputSecret.secretReminderRepeatDays,
|
||||
recipients: inputSecret.secretReminderRecipients
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
if (inputSecret.type === SecretType.Shared) {
|
||||
@ -1868,12 +1884,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
return {
|
||||
filter: { id: originalSecret.id, type: SecretType.Shared },
|
||||
data: {
|
||||
reminderRepeatDays: el.secretReminderRepeatDays,
|
||||
encryptedComment: setKnexStringValue(
|
||||
el.secretComment,
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
|
||||
),
|
||||
reminderNote: el.secretReminderNote,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||
key: el.newSecretName || el.secretKey,
|
||||
tags: el.tagIds,
|
||||
@ -2522,9 +2536,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
key: doc.key,
|
||||
encryptedComment: doc.encryptedComment,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||
reminderNote: doc.reminderNote,
|
||||
secretMetadata: doc.secretMetadata,
|
||||
reminderRepeatDays: doc.reminderRepeatDays,
|
||||
...(doc.encryptedValue
|
||||
? {
|
||||
encryptedValue: doc.encryptedValue,
|
||||
@ -2914,6 +2926,11 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const findSecretIdsByFolderIdAndKeys = async ({ folderId, keys }: { folderId: string; keys: string[] }) => {
|
||||
const secrets = await secretDAL.find({ folderId, $in: { [`${TableName.SecretV2}.key` as "key"]: keys } });
|
||||
return secrets.map((el) => ({ id: el.id, key: el.key }));
|
||||
};
|
||||
|
||||
return {
|
||||
createSecret,
|
||||
deleteSecret,
|
||||
@ -2933,6 +2950,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
getSecretsByFolderMappings,
|
||||
getSecretById,
|
||||
getAccessibleSecrets,
|
||||
getSecretVersionsByIds
|
||||
getSecretVersionsByIds,
|
||||
findSecretIdsByFolderIdAndKeys
|
||||
};
|
||||
};
|
||||
|
@ -246,6 +246,7 @@ export type TCreateSecretReminderDTO = {
|
||||
export type TRemoveSecretReminderDTO = {
|
||||
secretId: string;
|
||||
repeatDays: number;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TBackFillSecretReferencesDTO = TProjectPermission;
|
||||
|
@ -5,8 +5,6 @@ import { TDbClient } from "@app/db";
|
||||
import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
export type TSecretDALFactory = ReturnType<typeof secretDALFactory>;
|
||||
|
||||
@ -383,94 +381,6 @@ export const secretDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pruneSecretReminders = async (queueService: TQueueServiceFactory) => {
|
||||
const REMINDER_PRUNE_BATCH_SIZE = 5_000;
|
||||
const MAX_RETRY_ON_FAILURE = 3;
|
||||
let numberOfRetryOnFailure = 0;
|
||||
let deletedReminderCount = 0;
|
||||
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: secret reminders started`);
|
||||
|
||||
try {
|
||||
const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder);
|
||||
const reminderJobs = repeatableJobs
|
||||
.map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key }))
|
||||
.filter(Boolean);
|
||||
|
||||
if (reminderJobs.length === 0) {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: no reminder jobs found`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) {
|
||||
try {
|
||||
const batchIds = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE).map((r) => r.secretId);
|
||||
|
||||
const payload = {
|
||||
$in: {
|
||||
id: batchIds
|
||||
}
|
||||
};
|
||||
|
||||
const opts = {
|
||||
limit: REMINDER_PRUNE_BATCH_SIZE
|
||||
};
|
||||
|
||||
// Find existing secrets with pagination
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [secrets, secretsV2] = await Promise.all([
|
||||
ormify(db, TableName.Secret).find(payload, opts),
|
||||
ormify(db, TableName.SecretV2).find(payload, opts)
|
||||
]);
|
||||
|
||||
const foundSecretIds = new Set([
|
||||
...secrets.map((secret) => secret.id),
|
||||
...secretsV2.map((secret) => secret.id)
|
||||
]);
|
||||
|
||||
// Find IDs that don't exist in either table
|
||||
const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId));
|
||||
|
||||
// Delete reminders for non-existent secrets
|
||||
for (const secretId of secretIdsNotFound) {
|
||||
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
|
||||
|
||||
if (jobKey) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
|
||||
deletedReminderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
numberOfRetryOnFailure = 0;
|
||||
} catch (error) {
|
||||
numberOfRetryOnFailure += 1;
|
||||
logger.error(error, `Failed to process batch at offset ${offset}`);
|
||||
|
||||
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Retry the current batch
|
||||
offset -= REMINDER_PRUNE_BATCH_SIZE;
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure));
|
||||
}
|
||||
|
||||
// Small delay between batches
|
||||
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to complete secret reminder pruning");
|
||||
} finally {
|
||||
logger.info(
|
||||
`${QueueName.DailyResourceCleanUp}: secret reminders completed. Deleted ${deletedReminderCount} reminders`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...secretOrm,
|
||||
update,
|
||||
@ -485,7 +395,6 @@ export const secretDALFactory = (db: TDbClient) => {
|
||||
upsertSecretReferences,
|
||||
findReferencedSecretReferences,
|
||||
findAllProjectSecretValues,
|
||||
pruneSecretReminders,
|
||||
findManySecretsWithTags
|
||||
};
|
||||
};
|
||||
|
@ -20,11 +20,9 @@ import {
|
||||
decryptSymmetric128BitHexKeyUTF8,
|
||||
encryptSymmetric128BitHexKeyUTF8
|
||||
} from "@app/lib/crypto";
|
||||
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy, unique } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import {
|
||||
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
|
||||
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
|
||||
@ -36,6 +34,7 @@ import { KmsDataKey } from "../kms/kms-types";
|
||||
import { getBotKeyFnFactory } from "../project-bot/project-bot-fns";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { TSecretDALFactory } from "./secret-dal";
|
||||
@ -741,7 +740,8 @@ export const fnSecretBulkDelete = async ({
|
||||
tx,
|
||||
actorId,
|
||||
secretDAL,
|
||||
secretQueueService
|
||||
secretQueueService,
|
||||
projectId
|
||||
}: TFnSecretBulkDelete) => {
|
||||
const deletedSecrets = await secretDAL.deleteMany(
|
||||
inputSecrets.map(({ type, secretBlindIndex }) => ({
|
||||
@ -757,7 +757,10 @@ export const fnSecretBulkDelete = async ({
|
||||
deletedSecrets
|
||||
.filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays))
|
||||
.map(({ id, secretReminderRepeatDays }) =>
|
||||
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx)
|
||||
secretQueueService.removeSecretReminder(
|
||||
{ secretId: id, repeatDays: secretReminderRepeatDays as number, projectId },
|
||||
tx
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@ -1184,14 +1187,14 @@ export const decryptSecretWithBot = (
|
||||
type TFnDeleteProjectSecretReminders = {
|
||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId">;
|
||||
};
|
||||
|
||||
export const fnDeleteProjectSecretReminders = async (
|
||||
projectId: string,
|
||||
{ secretDAL, secretV2BridgeDAL, queueService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders
|
||||
{ secretDAL, secretV2BridgeDAL, reminderService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders
|
||||
) => {
|
||||
const projectFolders = await folderDAL.findByProjectId(projectId);
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId, false);
|
||||
@ -1206,23 +1209,13 @@ export const fnDeleteProjectSecretReminders = async (
|
||||
$notNull: ["secretReminderRepeatDays"]
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
for await (const secret of projectSecrets) {
|
||||
const repeatDays = shouldUseSecretV2Bridge
|
||||
? (secret as { reminderRepeatDays: number }).reminderRepeatDays
|
||||
: (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays;
|
||||
|
||||
// We're using the queue service directly to get around conflicting imports.
|
||||
if (repeatDays) {
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every: appCfg.NODE_ENV === "development" ? secondsToMillis(repeatDays) : daysToMillisecond(repeatDays)
|
||||
},
|
||||
`reminder-${secret.id}`
|
||||
);
|
||||
await reminderService.deleteReminderBySecretId(secret.id, projectId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -20,7 +20,6 @@ import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { getTimeDifferenceInSeconds, groupBy, isSamePath, unique } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
@ -41,7 +40,6 @@ import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-
|
||||
import { syncIntegrationSecrets } from "../integration-auth/integration-sync-secret";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { TOrgServiceFactory } from "../org/org-service";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { createProjectKey } from "../project/project-fns";
|
||||
@ -50,12 +48,12 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
||||
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
|
||||
import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
|
||||
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
|
||||
@ -93,7 +91,6 @@ type TSecretQueueFactoryDep = {
|
||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "create">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers" | "create">;
|
||||
smtpService: TSmtpService;
|
||||
orgDAL: Pick<TOrgDALFactory, "findOrgByProjectId">;
|
||||
secretVersionDAL: TSecretVersionDALFactory;
|
||||
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
|
||||
secretTagDAL: TSecretTagDALFactory;
|
||||
@ -113,11 +110,11 @@ type TSecretQueueFactoryDep = {
|
||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
secretReminderRecipientsDAL: Pick<
|
||||
TSecretReminderRecipientsDALFactory,
|
||||
"delete" | "findUsersBySecretId" | "insertMany" | "transaction"
|
||||
>;
|
||||
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
||||
reminderService: Pick<
|
||||
TReminderServiceFactory,
|
||||
"createReminderInternal" | "deleteReminderBySecretId" | "removeReminderRecipients"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TGetSecrets = {
|
||||
@ -155,7 +152,6 @@ export const secretQueueFactory = ({
|
||||
userDAL,
|
||||
webhookDAL,
|
||||
projectEnvDAL,
|
||||
orgDAL,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
@ -178,9 +174,9 @@ export const secretQueueFactory = ({
|
||||
projectUserMembershipRoleDAL,
|
||||
projectKeyDAL,
|
||||
resourceMetadataDAL,
|
||||
secretReminderRecipientsDAL,
|
||||
secretSyncQueue,
|
||||
folderCommitService
|
||||
folderCommitService,
|
||||
reminderService
|
||||
}: TSecretQueueFactoryDep) => {
|
||||
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
||||
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
|
||||
@ -190,19 +186,8 @@ export const secretQueueFactory = ({
|
||||
|
||||
const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => {
|
||||
if (deleteRecipients) {
|
||||
await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx);
|
||||
await reminderService.deleteReminderBySecretId(dto.secretId, dto.projectId, tx);
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every: appCfg.NODE_ENV === "development" ? secondsToMillis(dto.repeatDays) : daysToMillisecond(dto.repeatDays)
|
||||
},
|
||||
`reminder-${dto.secretId}`
|
||||
);
|
||||
};
|
||||
|
||||
const $generateActor = async (actorId?: string, isManual?: boolean): Promise<Actor> => {
|
||||
@ -242,11 +227,9 @@ export const secretQueueFactory = ({
|
||||
oldSecret,
|
||||
newSecret,
|
||||
projectId,
|
||||
deleteRecipients = true
|
||||
secretReminderRecipients
|
||||
}: TCreateSecretReminderDTO) => {
|
||||
try {
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (oldSecret.id !== newSecret.id) {
|
||||
throw new BadRequestError({
|
||||
name: "SecretReminderIdMismatch",
|
||||
@ -261,38 +244,13 @@ export const secretQueueFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
// If the secret already has a reminder, we should remove the existing one first.
|
||||
if (oldSecret.secretReminderRepeatDays) {
|
||||
await removeSecretReminder({
|
||||
repeatDays: oldSecret.secretReminderRepeatDays,
|
||||
secretId: oldSecret.id,
|
||||
deleteRecipients
|
||||
});
|
||||
}
|
||||
|
||||
await queueService.queue(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
note: newSecret.secretReminderNote,
|
||||
projectId,
|
||||
repeatDays: newSecret.secretReminderRepeatDays,
|
||||
secretId: newSecret.id
|
||||
},
|
||||
{
|
||||
jobId: `reminder-${newSecret.id}`,
|
||||
repeat: {
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every:
|
||||
appCfg.NODE_ENV === "development"
|
||||
? secondsToMillis(newSecret.secretReminderRepeatDays)
|
||||
: daysToMillisecond(newSecret.secretReminderRepeatDays),
|
||||
immediately: true
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
}
|
||||
);
|
||||
await reminderService.createReminderInternal({
|
||||
secretId: newSecret.id,
|
||||
message: newSecret.secretReminderNote,
|
||||
repeatDays: newSecret.secretReminderRepeatDays,
|
||||
recipients: secretReminderRecipients,
|
||||
projectId
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create secret reminder.");
|
||||
throw new BadRequestError({
|
||||
@ -305,55 +263,30 @@ export const secretQueueFactory = ({
|
||||
const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => {
|
||||
const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret;
|
||||
|
||||
const recipientsUpdated =
|
||||
secretReminderRecipients?.some(
|
||||
(newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId)
|
||||
) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length;
|
||||
|
||||
await secretReminderRecipientsDAL.transaction(async (tx) => {
|
||||
if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) {
|
||||
if (
|
||||
(secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
|
||||
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
|
||||
) {
|
||||
await addSecretReminder({
|
||||
oldSecret,
|
||||
newSecret,
|
||||
projectId,
|
||||
deleteRecipients: false
|
||||
});
|
||||
} else if (
|
||||
secretReminderRepeatDays === null &&
|
||||
secretReminderNote === null &&
|
||||
oldSecret.secretReminderRepeatDays
|
||||
) {
|
||||
await removeSecretReminder({
|
||||
secretId: oldSecret.id,
|
||||
repeatDays: oldSecret.secretReminderRepeatDays
|
||||
});
|
||||
}
|
||||
if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) {
|
||||
if (
|
||||
(secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
|
||||
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
|
||||
) {
|
||||
await addSecretReminder({
|
||||
oldSecret,
|
||||
newSecret,
|
||||
projectId,
|
||||
secretReminderRecipients: secretReminderRecipients ?? [],
|
||||
deleteRecipients: false
|
||||
});
|
||||
} else if (
|
||||
secretReminderRepeatDays === null &&
|
||||
secretReminderNote === null &&
|
||||
oldSecret.secretReminderRepeatDays
|
||||
) {
|
||||
await removeSecretReminder({
|
||||
secretId: oldSecret.id,
|
||||
repeatDays: oldSecret.secretReminderRepeatDays,
|
||||
projectId
|
||||
});
|
||||
}
|
||||
|
||||
if (recipientsUpdated) {
|
||||
// if no recipients, delete all existing recipients
|
||||
if (!secretReminderRecipients?.length) {
|
||||
const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx);
|
||||
if (existingRecipients) {
|
||||
await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx);
|
||||
}
|
||||
} else {
|
||||
await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx);
|
||||
await secretReminderRecipientsDAL.insertMany(
|
||||
secretReminderRecipients.map((r) => ({
|
||||
secretId: newSecret.id,
|
||||
userId: r,
|
||||
projectId
|
||||
})),
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const createManySecretsRawFn = createManySecretsRawFnFactory({
|
||||
projectDAL,
|
||||
@ -1111,62 +1044,9 @@ export const secretQueueFactory = ({
|
||||
}
|
||||
});
|
||||
|
||||
// TODO(Carlos): remove this queue (needed for queue initialization and perform the migration)
|
||||
queueService.start(QueueName.SecretReminder, async ({ data }) => {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}]`);
|
||||
|
||||
const { projectId } = data;
|
||||
|
||||
const organization = await orgDAL.findOrgByProjectId(projectId);
|
||||
const project = await projectDAL.findById(projectId);
|
||||
const secret = await secretV2BridgeDAL.findById(data.secretId);
|
||||
const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]);
|
||||
|
||||
const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId);
|
||||
|
||||
if (!organization) {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no project found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
|
||||
|
||||
if (!projectMembers || !projectMembers.length) {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no project members found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRecipients = recipients?.length
|
||||
? recipients.map((r) => r.email as string)
|
||||
: projectMembers.map((m) => m.user.email as string);
|
||||
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.SecretReminder,
|
||||
subjectLine: "Infisical secret reminder",
|
||||
recipients: selectedRecipients,
|
||||
substitutions: {
|
||||
reminderNote: data.note, // May not be present.
|
||||
projectName: project.name,
|
||||
organizationName: organization.name
|
||||
}
|
||||
});
|
||||
|
||||
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, {
|
||||
type: WebhookEvents.SecretReminderExpired,
|
||||
payload: {
|
||||
projectName: project.name,
|
||||
projectId: project.id,
|
||||
secretPath: folder?.path,
|
||||
environment: folder?.environmentSlug || "",
|
||||
reminderNote: data.note,
|
||||
secretName: secret?.key,
|
||||
secretId: data.secretId
|
||||
}
|
||||
});
|
||||
logger.info(`(deprecated) secretReminderQueue.process: [secretDocument=${data.secretId}]`);
|
||||
});
|
||||
|
||||
const startSecretV2Migration = async (projectId: string) => {
|
||||
|
@ -48,6 +48,7 @@ import { ChangeType } from "../folder-commit/folder-commit-service";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TReminderServiceFactory } from "../reminder/reminder-types";
|
||||
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
@ -131,6 +132,7 @@ type TSecretServiceFactoryDep = {
|
||||
"insertMany" | "insertApprovalSecretTags"
|
||||
>;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
reminderService: Pick<TReminderServiceFactory, "createReminder">;
|
||||
};
|
||||
|
||||
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
|
||||
@ -153,7 +155,8 @@ export const secretServiceFactory = ({
|
||||
secretApprovalRequestSecretDAL,
|
||||
secretV2BridgeService,
|
||||
secretApprovalRequestService,
|
||||
licenseService
|
||||
licenseService,
|
||||
reminderService
|
||||
}: TSecretServiceFactoryDep) => {
|
||||
const getSecretReference = async (projectId: string) => {
|
||||
// if bot key missing means e2e still exist
|
||||
@ -546,7 +549,8 @@ export const secretServiceFactory = ({
|
||||
await secretQueueService.removeSecretReminder(
|
||||
{
|
||||
repeatDays: secret.secretReminderRepeatDays,
|
||||
secretId: secret.id
|
||||
secretId: secret.id,
|
||||
projectId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@ -1072,7 +1076,8 @@ export const secretServiceFactory = ({
|
||||
await secretQueueService.removeSecretReminder(
|
||||
{
|
||||
repeatDays: secret.secretReminderRepeatDays,
|
||||
secretId: secret.id
|
||||
secretId: secret.id,
|
||||
projectId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@ -1660,8 +1665,6 @@ export const secretServiceFactory = ({
|
||||
secretComment,
|
||||
secretValue,
|
||||
tagIds,
|
||||
reminderNote: secretReminderNote,
|
||||
reminderRepeatDays: secretReminderRepeatDays,
|
||||
secretMetadata
|
||||
}
|
||||
]
|
||||
@ -1824,9 +1827,6 @@ export const secretServiceFactory = ({
|
||||
secretComment,
|
||||
secretValue,
|
||||
tagIds,
|
||||
reminderNote: secretReminderNote,
|
||||
reminderRepeatDays: secretReminderRepeatDays,
|
||||
secretReminderRecipients,
|
||||
secretMetadata
|
||||
}
|
||||
]
|
||||
@ -1835,9 +1835,6 @@ export const secretServiceFactory = ({
|
||||
return { type: SecretProtectionType.Approval as const, approval };
|
||||
}
|
||||
const secret = await secretV2BridgeService.updateSecret({
|
||||
secretReminderRepeatDays,
|
||||
secretReminderNote,
|
||||
secretReminderRecipients,
|
||||
skipMultilineEncoding,
|
||||
tagIds,
|
||||
secretComment,
|
||||
@ -1855,6 +1852,22 @@ export const secretServiceFactory = ({
|
||||
secretValue,
|
||||
secretMetadata
|
||||
});
|
||||
|
||||
if (secretReminderRepeatDays) {
|
||||
await reminderService.createReminder({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder: {
|
||||
secretId: secret.id,
|
||||
message: secretReminderNote,
|
||||
repeatDays: secretReminderRepeatDays,
|
||||
recipients: secretReminderRecipients
|
||||
}
|
||||
});
|
||||
}
|
||||
return { type: SecretProtectionType.Direct as const, secret };
|
||||
}
|
||||
|
||||
@ -2246,6 +2259,30 @@ export const secretServiceFactory = ({
|
||||
secrets: inputSecrets,
|
||||
mode
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
inputSecrets
|
||||
.filter((el) => el.secretReminderRepeatDays)
|
||||
.map(async (secret) => {
|
||||
await reminderService.createReminder({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
reminder: {
|
||||
secretId: secrets.find(
|
||||
(el) =>
|
||||
(el.secretKey === secret.secretKey || el.secretKey === secret.newSecretName) &&
|
||||
el.secretPath === (secret.secretPath || secretPath)
|
||||
)?.id,
|
||||
message: secret.secretReminderNote,
|
||||
repeatDays: secret.secretReminderRepeatDays
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return { type: SecretProtectionType.Direct as const, secrets };
|
||||
}
|
||||
|
||||
|
@ -310,6 +310,7 @@ export type TUpdateManySecretRawDTO = Omit<TProjectPermission, "projectId"> & {
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
secretPath?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -410,6 +411,7 @@ export type TCreateSecretReminderDTO = {
|
||||
oldSecret: TPartialSecret;
|
||||
newSecret: TPartialSecret;
|
||||
projectId: string;
|
||||
secretReminderRecipients: string[];
|
||||
|
||||
deleteRecipients?: boolean;
|
||||
};
|
||||
@ -417,6 +419,7 @@ export type TCreateSecretReminderDTO = {
|
||||
export type TRemoveSecretReminderDTO = {
|
||||
secretId: string;
|
||||
repeatDays: number;
|
||||
projectId: string;
|
||||
deleteRecipients?: boolean;
|
||||
};
|
||||
|
||||
|
@ -18,6 +18,7 @@ export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
|
||||
popUpProps: PopoverProps;
|
||||
popUpContentProps: PopoverContentProps;
|
||||
dateFormat?: "PPP" | "PP" | "P"; // extend as needed
|
||||
hideTime?: boolean;
|
||||
};
|
||||
|
||||
// Doc: https://react-day-picker.js.org/
|
||||
@ -27,6 +28,7 @@ export const DatePicker = ({
|
||||
popUpProps,
|
||||
popUpContentProps,
|
||||
dateFormat = "PPP",
|
||||
hideTime = false,
|
||||
...props
|
||||
}: DatePickerProps) => {
|
||||
const [timeValue, setTimeValue] = useState<string>(value ? format(value, "HH:mm") : "00:00");
|
||||
@ -85,14 +87,16 @@ export const DatePicker = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-4 my-4">
|
||||
<Input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-mineshaft-700 text-white [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
{!hideTime && (
|
||||
<div className="mx-4 my-4">
|
||||
<Input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-mineshaft-700 text-white [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
1
frontend/src/hooks/api/reminders/index.tsx
Normal file
1
frontend/src/hooks/api/reminders/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { useCreateReminder, useDeleteReminder, useGetReminder } from "./queries";
|
61
frontend/src/hooks/api/reminders/queries.tsx
Normal file
61
frontend/src/hooks/api/reminders/queries.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { CreateReminderDTO, DeleteReminderDTO, Reminder } from "./types";
|
||||
|
||||
export const reminderKeys = {
|
||||
getReminder: (secretId: string, projectId: string) =>
|
||||
["get-reminder", secretId, projectId] as const
|
||||
};
|
||||
|
||||
export const useCreateReminder = (secretId: string, projectId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Reminder, object, CreateReminderDTO>({
|
||||
mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => {
|
||||
const { data } = await apiRequest.post<{ reminder: Reminder }>(
|
||||
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`,
|
||||
{
|
||||
message,
|
||||
repeatDays,
|
||||
nextReminderDate,
|
||||
recipients
|
||||
}
|
||||
);
|
||||
return data.reminder;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteReminder = (secretId: string, projectId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Reminder, object, DeleteReminderDTO>({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiRequest.delete<{ reminder: Reminder }>(
|
||||
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`
|
||||
);
|
||||
return data.reminder;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetReminder = (secretId: string, projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: reminderKeys.getReminder(secretId, projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ reminder: Reminder }>(
|
||||
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`
|
||||
);
|
||||
return data.reminder;
|
||||
},
|
||||
enabled: Boolean(secretId && projectId)
|
||||
});
|
||||
};
|
14
frontend/src/hooks/api/reminders/types.ts
Normal file
14
frontend/src/hooks/api/reminders/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export type CreateReminderDTO = {
|
||||
message?: string | null;
|
||||
repeatDays?: number | null;
|
||||
nextReminderDate?: Date | null;
|
||||
secretId: string;
|
||||
recipients?: string[];
|
||||
};
|
||||
|
||||
export type DeleteReminderDTO = {
|
||||
secretId: string;
|
||||
reminderId: string;
|
||||
};
|
||||
|
||||
export type Reminder = { id: string } & CreateReminderDTO;
|
@ -1,25 +1,65 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faClock, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
import { useCreateReminder, useDeleteReminder } from "@app/hooks/api/reminders";
|
||||
import { reminderKeys } from "@app/hooks/api/reminders/queries";
|
||||
import { Reminder } from "@app/hooks/api/reminders/types";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
|
||||
// Constants
|
||||
const MIN_REPEAT_DAYS = 1;
|
||||
const MAX_REPEAT_DAYS = 365;
|
||||
const DEFAULT_REPEAT_DAYS = 30;
|
||||
const DEFAULT_TEXTAREA_ROWS = 8;
|
||||
|
||||
// Enums
|
||||
enum ReminderType {
|
||||
Recurring = "Recurring",
|
||||
OneTime = "One Time"
|
||||
}
|
||||
|
||||
// Types
|
||||
interface RecipientOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ReminderFormProps {
|
||||
isOpen: boolean;
|
||||
reminderId?: string;
|
||||
onOpenChange: () => void;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
reminder?: Reminder;
|
||||
}
|
||||
|
||||
// Validation Schema
|
||||
const ReminderFormSchema = z.object({
|
||||
note: z.string().optional().nullable(),
|
||||
message: z.string().optional().nullable(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
@ -28,182 +68,404 @@ const ReminderFormSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
days: z
|
||||
repeatDays: z
|
||||
.number()
|
||||
.min(1, { message: "Must be at least 1 day" })
|
||||
.max(365, { message: "Must be less than 365 days" })
|
||||
.min(MIN_REPEAT_DAYS, { message: `Must be at least ${MIN_REPEAT_DAYS} day` })
|
||||
.max(MAX_REPEAT_DAYS, { message: `Must be less than ${MAX_REPEAT_DAYS} days` })
|
||||
.nullable()
|
||||
.optional(),
|
||||
nextReminderDate: z.coerce
|
||||
.date()
|
||||
.refine((data) => data > new Date(), { message: "Reminder date must be in the future" })
|
||||
.nullable()
|
||||
.optional(),
|
||||
reminderType: z.enum(["Recurring", "One Time"])
|
||||
});
|
||||
|
||||
export type TReminderFormSchema = z.infer<typeof ReminderFormSchema>;
|
||||
|
||||
interface ReminderFormProps {
|
||||
isOpen: boolean;
|
||||
repeatDays?: number | null;
|
||||
note?: string | null;
|
||||
recipients?: string[] | null;
|
||||
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
|
||||
}
|
||||
// Custom hook for form state management
|
||||
const useReminderForm = (reminderData?: Reminder) => {
|
||||
const { repeatDays, message, nextReminderDate } = reminderData || {};
|
||||
|
||||
const isEditMode = Boolean(reminderData);
|
||||
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
repeatDays: repeatDays || null,
|
||||
message: message || "",
|
||||
nextReminderDate: nextReminderDate || null,
|
||||
reminderType: repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
|
||||
recipients: []
|
||||
}),
|
||||
[repeatDays, message, nextReminderDate]
|
||||
);
|
||||
|
||||
return {
|
||||
isEditMode,
|
||||
reminderData,
|
||||
defaultValues
|
||||
};
|
||||
};
|
||||
|
||||
// Custom hook for workspace members
|
||||
const useWorkspaceMembers = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
|
||||
|
||||
const memberOptions = useMemo(
|
||||
(): RecipientOption[] =>
|
||||
members.map((member) => ({
|
||||
label: member.user.username || member.user.email,
|
||||
value: member.user.id
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
return { members, memberOptions };
|
||||
};
|
||||
|
||||
// Main component
|
||||
export const CreateReminderForm = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
repeatDays,
|
||||
note,
|
||||
recipients
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
secretId,
|
||||
reminder
|
||||
}: ReminderFormProps) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const queryClient = useQueryClient();
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||||
|
||||
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
|
||||
// Custom hooks
|
||||
const { isEditMode, reminderData } = useReminderForm(reminder);
|
||||
const { memberOptions } = useWorkspaceMembers();
|
||||
|
||||
// API mutations
|
||||
const { mutateAsync: createReminder } = useCreateReminder(secretId, workspaceId);
|
||||
const { mutateAsync: deleteReminder } = useDeleteReminder(secretId, workspaceId);
|
||||
|
||||
// Form setup
|
||||
const form = useForm<TReminderFormSchema>({
|
||||
defaultValues: {
|
||||
repeatDays: reminderData?.repeatDays || null,
|
||||
message: reminderData?.message || "",
|
||||
nextReminderDate: reminderData?.nextReminderDate || null,
|
||||
reminderType: reminderData?.repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
|
||||
recipients: []
|
||||
},
|
||||
resolver: zodResolver(ReminderFormSchema)
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TReminderFormSchema>({
|
||||
defaultValues: {
|
||||
days: repeatDays || undefined,
|
||||
note: note || ""
|
||||
},
|
||||
resolver: zodResolver(ReminderFormSchema)
|
||||
});
|
||||
} = form;
|
||||
|
||||
const handleFormSubmit = async (data: TReminderFormSchema) => {
|
||||
onOpenChange(false, data);
|
||||
// Watch form values
|
||||
const reminderType = watch("reminderType");
|
||||
|
||||
// Invalidate queries helper
|
||||
const invalidateQueries = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dashboardKeys.getDashboardSecrets({
|
||||
projectId: workspaceId,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: reminderKeys.getReminder(secretId, workspaceId)
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// On initial load, filter the members to only include the recipients
|
||||
if (members.length) {
|
||||
const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id));
|
||||
setValue(
|
||||
"recipients",
|
||||
filteredMembers.map((m) => ({
|
||||
label: m.user.username || m.user.email,
|
||||
value: m.user.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [members, isOpen, recipients]);
|
||||
// Form submission handler
|
||||
const handleFormSubmit = async (data: TReminderFormSchema) => {
|
||||
try {
|
||||
await createReminder({
|
||||
repeatDays: data.repeatDays,
|
||||
message: data.message,
|
||||
recipients: data.recipients?.map((r) => r.value) || [],
|
||||
secretId,
|
||||
nextReminderDate: data.nextReminderDate
|
||||
});
|
||||
|
||||
invalidateQueries();
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully ${isEditMode ? "updated" : "created"} secret reminder`
|
||||
});
|
||||
|
||||
reset();
|
||||
onOpenChange();
|
||||
} catch (error) {
|
||||
console.error("Failed to save reminder:", error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to save reminder. Please try again."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete reminder handler
|
||||
const handleDeleteReminder = async () => {
|
||||
try {
|
||||
await deleteReminder({ reminderId: reminder?.id || "", secretId });
|
||||
invalidateQueries();
|
||||
reset();
|
||||
onOpenChange();
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted reminder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete reminder:", error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete reminder. Please try again."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reminder type change
|
||||
const handleReminderTypeChange = useCallback(
|
||||
(newType: string) => {
|
||||
if (newType === ReminderType.Recurring) {
|
||||
setValue("repeatDays", DEFAULT_REPEAT_DAYS);
|
||||
setValue("nextReminderDate", null);
|
||||
} else if (newType === ReminderType.OneTime) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
setValue("nextReminderDate", tomorrow);
|
||||
setValue("repeatDays", null);
|
||||
}
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
// Initialize form with existing data
|
||||
useEffect(() => {
|
||||
if (repeatDays) setValue("days", repeatDays);
|
||||
if (note) setValue("note", note);
|
||||
}, [repeatDays, note]);
|
||||
if (!reminderData) return;
|
||||
|
||||
const {
|
||||
repeatDays: repeatDaysInitial,
|
||||
message,
|
||||
recipients,
|
||||
nextReminderDate: nextReminderDateInitial
|
||||
} = reminderData;
|
||||
|
||||
if (repeatDaysInitial) {
|
||||
setValue("repeatDays", repeatDaysInitial);
|
||||
setValue("reminderType", ReminderType.Recurring);
|
||||
} else {
|
||||
setValue("reminderType", ReminderType.OneTime);
|
||||
}
|
||||
|
||||
if (message) setValue("message", message);
|
||||
if (nextReminderDateInitial) setValue("nextReminderDate", nextReminderDateInitial);
|
||||
|
||||
// Set recipients
|
||||
if (recipients?.length && memberOptions.length) {
|
||||
const selectedRecipients = memberOptions.filter((option) =>
|
||||
recipients.includes(option.value)
|
||||
);
|
||||
setValue("recipients", selectedRecipients);
|
||||
}
|
||||
}, [reminderData, memberOptions, setValue]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title={`${repeatDays ? "Update" : "Create"} reminder`}
|
||||
title={`${isEditMode ? "Update" : "Create"} reminder`}
|
||||
subTitle="Set up a reminder for when this secret should be rotated. Everyone with access to this project will be notified when the reminder is triggered."
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="days"
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<FormControl
|
||||
isRequired
|
||||
className="mb-0"
|
||||
label="Reminder Interval (in days)"
|
||||
isError={Boolean(fieldState.error)}
|
||||
errorText={fieldState.error?.message || ""}
|
||||
>
|
||||
<Input
|
||||
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
|
||||
type="number"
|
||||
placeholder="31"
|
||||
defaultValue={repeatDays || undefined}
|
||||
value={field.value || undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-1 mt-2 text-xs",
|
||||
field.value ? "opacity-60" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
A reminder will be sent every{" "}
|
||||
{field.value && field.value > 1 ? `${field.value} days` : "day"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormControl label="Note" className="mb-0">
|
||||
<TextArea
|
||||
placeholder="Remember to rotate the AWS secret every month."
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
rows={8}
|
||||
defaultValue={note || ""}
|
||||
reSize="none"
|
||||
cols={30}
|
||||
{...register("note")}
|
||||
/>
|
||||
</FormControl>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Reminder Type Selection */}
|
||||
<Controller
|
||||
name="reminderType"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Recurrence"
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
onChange(val);
|
||||
handleReminderTypeChange(val);
|
||||
}}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
placeholder="Select reminder recurrence"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(ReminderType).map((type) => (
|
||||
<SelectItem value={type} key={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Conditional Fields Based on Reminder Type */}
|
||||
{reminderType === ReminderType.Recurring ? (
|
||||
<Controller
|
||||
control={control}
|
||||
name="recipients"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
tooltipText={
|
||||
<div>
|
||||
Select users to receive reminders.
|
||||
<br />
|
||||
<br /> If none are selected, all project members will receive the reminder.
|
||||
</div>
|
||||
}
|
||||
label="Recipients"
|
||||
className="mb-0"
|
||||
>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Select reminder recipients..."
|
||||
isMulti
|
||||
name="recipients"
|
||||
options={members.map((member) => ({
|
||||
label: member.user.username || member.user.email,
|
||||
value: member.user.id
|
||||
}))}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
name="repeatDays"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<FormControl
|
||||
isRequired
|
||||
className="mb-0"
|
||||
label="Reminder Interval (in days)"
|
||||
isError={Boolean(fieldState.error)}
|
||||
errorText={fieldState.error?.message || ""}
|
||||
>
|
||||
<Input
|
||||
onChange={(el) => {
|
||||
const value = parseInt(el.target.value, 10);
|
||||
setValue("repeatDays", Number.isNaN(value) ? null : value);
|
||||
}}
|
||||
type="number"
|
||||
placeholder={DEFAULT_REPEAT_DAYS.toString()}
|
||||
value={field.value || ""}
|
||||
min={MIN_REPEAT_DAYS}
|
||||
max={MAX_REPEAT_DAYS}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Interval description */}
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-1 mt-2 text-xs",
|
||||
field.value ? "opacity-60" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
A reminder will be sent every{" "}
|
||||
{field.value && field.value > 1 ? `${field.value} days` : "day"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center space-x-4">
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name="nextReminderDate"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<FormControl
|
||||
isRequired
|
||||
className="mb-0"
|
||||
label="Reminder Date"
|
||||
isError={Boolean(fieldState.error)}
|
||||
errorText={fieldState.error?.message || ""}
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
className="w-full"
|
||||
onChange={field.onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isDatePickerOpen,
|
||||
onOpenChange: setIsDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
hideTime
|
||||
hidden={{ before: new Date(Date.now() + 86400000) }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message/Note Field */}
|
||||
<FormControl label="Note" className="mb-0">
|
||||
<TextArea
|
||||
placeholder="Remember to rotate the AWS secret every month."
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
rows={DEFAULT_TEXTAREA_ROWS}
|
||||
reSize="none"
|
||||
cols={30}
|
||||
{...register("message")}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Recipients Selection */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="recipients"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
tooltipText={
|
||||
<div>
|
||||
Select users to receive reminders.
|
||||
<br />
|
||||
<br /> If none are selected, all project members will receive the reminder.
|
||||
</div>
|
||||
}
|
||||
label="Recipients"
|
||||
className="mb-0"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
className="w-full"
|
||||
placeholder="Select reminder recipients..."
|
||||
isMulti
|
||||
name="recipients"
|
||||
options={memberOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center space-x-4 pt-4">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className=""
|
||||
leftIcon={<FontAwesomeIcon icon={faClock} />}
|
||||
type="submit"
|
||||
>
|
||||
{repeatDays ? "Update" : "Create"} reminder
|
||||
{isEditMode ? "Update" : "Create"} reminder
|
||||
</Button>
|
||||
{repeatDays && (
|
||||
|
||||
{isEditMode && (
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={() => onOpenChange(false, { days: null, note: null })}
|
||||
onClick={handleDeleteReminder}
|
||||
colorSchema="danger"
|
||||
variant="outline"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
type="button"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Delete reminder
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={onOpenChange}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
type="button"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons";
|
||||
@ -55,6 +55,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetSecretVersion } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { useGetReminder } from "@app/hooks/api/reminders";
|
||||
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
@ -114,6 +115,7 @@ export const SecretDetailSidebar = ({
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: reminderData } = useGetReminder(secret?.id, currentWorkspace.id);
|
||||
|
||||
const tagFields = useFieldArray({
|
||||
control,
|
||||
@ -220,23 +222,8 @@ export const SecretDetailSidebar = ({
|
||||
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
|
||||
};
|
||||
|
||||
const handleReminderSubmit = async (
|
||||
reminderRepeatDays: number | null | undefined,
|
||||
reminderNote: string | null | undefined,
|
||||
reminderRecipients: string[] | undefined
|
||||
) => {
|
||||
await onSaveSecret(
|
||||
secret,
|
||||
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true, reminderRecipients },
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
|
||||
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
|
||||
|
||||
const secretReminderRepeatDays = watch("reminderRepeatDays");
|
||||
const secretReminderNote = watch("reminderNote");
|
||||
const secretReminderRecipients = watch("reminderRecipients");
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
"reminderRecipients",
|
||||
@ -302,27 +289,33 @@ export const SecretDetailSidebar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getDaysUntilReminder = useMemo(() => {
|
||||
return (): string => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
const target = new Date(reminderData?.nextReminderDate || "");
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = target.getTime() - now.getTime();
|
||||
const daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return `Days until next reminder: ${daysRemaining}`;
|
||||
};
|
||||
}, [reminderData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateReminderForm
|
||||
repeatDays={secretReminderRepeatDays}
|
||||
note={secretReminderNote}
|
||||
recipients={secretReminderRecipients}
|
||||
isOpen={createReminderFormOpen}
|
||||
onOpenChange={(_, data) => {
|
||||
onOpenChange={() => {
|
||||
setCreateReminderFormOpen.toggle();
|
||||
|
||||
if (data) {
|
||||
const recipients = data.recipients?.length
|
||||
? data.recipients.map((recipient) => recipient.value)
|
||||
: undefined;
|
||||
|
||||
setValue("reminderRepeatDays", data.days, { shouldDirty: false });
|
||||
setValue("reminderNote", data.note, { shouldDirty: false });
|
||||
setValue("reminderRecipients", recipients, { shouldDirty: false });
|
||||
handleReminderSubmit(data.days, data.note, recipients);
|
||||
}
|
||||
}}
|
||||
workspaceId={currentWorkspace.id}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
secretId={secret?.id}
|
||||
reminder={reminderData}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.secretAccessUpgradePlan.isOpen}
|
||||
@ -734,14 +727,11 @@ export const SecretDetailSidebar = ({
|
||||
)}
|
||||
/>
|
||||
<FormControl>
|
||||
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
|
||||
{reminderData && reminderData.nextReminderDate ? (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FontAwesomeIcon className="text-primary-500" icon={faClock} />
|
||||
<span className="text-sm text-bunker-300">
|
||||
Reminder every {secretReminderRepeatDays}{" "}
|
||||
{secretReminderRepeatDays > 1 ? "days" : "day"}
|
||||
</span>
|
||||
<span className="text-sm text-bunker-300">{getDaysUntilReminder()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
|
Reference in New Issue
Block a user