diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index 58eebdedf..04a78bcd1 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -24,6 +24,7 @@ export const mockQueue = (): TQueueServiceFactory => { events[name] = event; }, getRepeatableJobs: async () => [], + getDelayedJobs: async () => [], clearQueue: async () => {}, stopJobById: async () => {}, stopJobByIdPg: async () => {}, diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2956be192..1d2461acf 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -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 diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7ead9f84b..7fac20c0e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -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; + [TableName.ReminderRecipient]: KnexOriginal.CompositeTableType< + TRemindersRecipients, + TRemindersRecipientsInsert, + TRemindersRecipientsUpdate + >; } } diff --git a/backend/src/db/migrations/20250701202824_add-reminder-table.ts b/backend/src/db/migrations/20250701202824_add-reminder-table.ts new file mode 100644 index 000000000..f1f5bc84d --- /dev/null +++ b/backend/src/db/migrations/20250701202824_add-reminder-table.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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.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 { + await dropOnUpdateTrigger(knex, TableName.Reminder); + await dropOnUpdateTrigger(knex, TableName.ReminderRecipient); + await knex.schema.dropTableIfExists(TableName.ReminderRecipient); + await knex.schema.dropTableIfExists(TableName.Reminder); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index d47962110..2e71486bf 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -160,7 +160,7 @@ export enum TableName { SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", MicrosoftTeamsIntegrations = "microsoft_teams_integrations", ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", - SecretReminderRecipients = "secret_reminder_recipients", + SecretReminderRecipients = "secret_reminder_recipients", // TODO(Carlos): Remove this in the future after migrating to the new reminder recipients table GithubOrgSyncConfig = "github_org_sync_configs", FolderCommit = "folder_commits", FolderCommitChanges = "folder_commit_changes", @@ -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"; diff --git a/backend/src/db/schemas/reminders-recipients.ts b/backend/src/db/schemas/reminders-recipients.ts new file mode 100644 index 000000000..8e02dfc35 --- /dev/null +++ b/backend/src/db/schemas/reminders-recipients.ts @@ -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; +export type TRemindersRecipientsInsert = Omit, TImmutableDBKeys>; +export type TRemindersRecipientsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/reminders.ts b/backend/src/db/schemas/reminders.ts new file mode 100644 index 000000000..6656ad077 --- /dev/null +++ b/backend/src/db/schemas/reminders.ts @@ -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; +export type TRemindersInsert = Omit, TImmutableDBKeys>; +export type TRemindersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 83458e691..818bb1f99 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -468,7 +468,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[] = [ @@ -3326,6 +3330,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; // not needed, based off projectId @@ -3689,4 +3718,7 @@ export type Event = | OrgUpdateEvent | ProjectCreateEvent | ProjectUpdateEvent - | ProjectDeleteEvent; + | ProjectDeleteEvent + | SecretReminderCreateEvent + | SecretReminderGetEvent + | SecretReminderDeleteEvent; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 9f4d1c67f..bcef34d4f 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -64,7 +64,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 { @@ -104,7 +106,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 = { @@ -291,6 +295,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; @@ -390,6 +402,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 = ( @@ -552,6 +569,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); @@ -598,6 +622,7 @@ export const queueServiceFactory = ( stopJobById, stopJobByIdPg, getRepeatableJobs, + getDelayedJobs, startPg, queuePg, schedulePg diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a1e336844..ea4cf9676 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -246,6 +246,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"; @@ -371,6 +375,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); @@ -734,9 +741,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, @@ -762,7 +777,8 @@ export const registerRoutes = async ( orgBotDAL, oidcConfigDAL, loginService, - projectBotService + projectBotService, + reminderService }); const signupService = authSignupServiceFactory({ tokenService, @@ -1060,7 +1076,6 @@ export const registerRoutes = async ( secretImportDAL, projectEnvDAL, webhookDAL, - orgDAL, auditLogService, userDAL, projectMembershipDAL, @@ -1082,11 +1097,11 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, - secretReminderRecipientsDAL, orgService, resourceMetadataDAL, folderCommitService, - secretSyncQueue + secretSyncQueue, + reminderService }); const projectService = projectServiceFactory({ @@ -1095,7 +1110,6 @@ export const registerRoutes = async ( projectSshConfigDAL, secretDAL, secretV2BridgeDAL, - queueService, projectQueue: projectQueueService, projectBotService, identityProjectDAL, @@ -1132,7 +1146,8 @@ export const registerRoutes = async ( microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, - smtpService + smtpService, + reminderService }); const projectEnvService = projectEnvServiceFactory({ @@ -1231,6 +1246,7 @@ export const registerRoutes = async ( kmsService, snapshotService, resourceMetadataDAL, + reminderService, keyStore }); @@ -1284,7 +1300,8 @@ export const registerRoutes = async ( secretApprovalRequestSecretDAL, secretV2BridgeService, secretApprovalRequestService, - licenseService + licenseService, + reminderService }); const secretSharingService = secretSharingServiceFactory({ @@ -1616,7 +1633,6 @@ export const registerRoutes = async ( auditLogDAL, queueService, secretVersionDAL, - secretDAL, secretFolderVersionDAL: folderVersionDAL, snapshotDAL, identityAccessTokenDAL, @@ -1627,6 +1643,13 @@ export const registerRoutes = async ( orgService }); + const dailyReminderQueueService = dailyReminderQueueServiceFactory({ + reminderService, + queueService, + secretDAL: secretV2BridgeDAL, + secretReminderRecipientsDAL + }); + const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ queueService, pkiAlertService @@ -1926,6 +1949,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(); @@ -2036,7 +2061,8 @@ export const registerRoutes = async ( assumePrivileges: assumePrivilegeService, githubOrgSync: githubOrgSyncConfigService, folderCommit: folderCommitService, - secretScanningV2: secretScanningV2Service + secretScanningV2: secretScanningV2Service, + reminder: reminderService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index e6aa2e83f..848bd4f31 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -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" } + ); }; diff --git a/backend/src/server/routes/v1/reminder-routers/index.ts b/backend/src/server/routes/v1/reminder-routers/index.ts new file mode 100644 index 000000000..863915c9c --- /dev/null +++ b/backend/src/server/routes/v1/reminder-routers/index.ts @@ -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 Promise> = + { + [ReminderType.SECRETS]: registerSecretReminderRouter + }; diff --git a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts new file mode 100644 index 000000000..4aa68197f --- /dev/null +++ b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts @@ -0,0 +1,154 @@ +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() + }), + 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, + 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, + 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() + }), + 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 + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + 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() + }), + 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 + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_SECRET_REMINDER, + metadata: { + secretId: req.params.secretId + } + } + }); + return { message: "Successfully deleted reminder" }; + } + }); +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index ca071e27f..5f60bfe1e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -47,7 +47,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"; @@ -65,6 +65,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"; @@ -132,8 +133,8 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; - queueService: Pick; loginService: Pick; + reminderService: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -165,8 +166,8 @@ export const orgServiceFactory = ({ projectUserMembershipRoleDAL, identityMetadataDAL, projectBotService, - queueService, - loginService + loginService, + reminderService }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -609,7 +610,7 @@ export const orgServiceFactory = ({ await fnDeleteProjectSecretReminders(project.id, { secretDAL, secretV2BridgeDAL, - queueService, + reminderService, projectBotService, folderDAL }); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 5d50c6898..27925c4b9 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -39,7 +39,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"; @@ -67,6 +66,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"; @@ -169,7 +169,6 @@ type TProjectServiceFactoryDep = { permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; - queueService: Pick; smtpService: Pick; orgDAL: Pick; keyStore: Pick; @@ -186,6 +185,7 @@ type TProjectServiceFactoryDep = { | "createCipherPairWithDataKey" >; projectTemplateService: TProjectTemplateServiceFactory; + reminderService: Pick; }; export type TProjectServiceFactory = ReturnType; @@ -198,7 +198,6 @@ export const projectServiceFactory = ({ projectQueue, projectKeyDAL, permissionService, - queueService, projectBotService, orgDAL, userDAL, @@ -233,7 +232,8 @@ export const projectServiceFactory = ({ microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, - smtpService + smtpService, + reminderService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -574,7 +574,7 @@ export const projectServiceFactory = ({ await fnDeleteProjectSecretReminders(project.id, { secretDAL, secretV2BridgeDAL, - queueService, + reminderService, projectBotService, folderDAL }); diff --git a/backend/src/services/reminder-recipients/reminder-recipient-dal.ts b/backend/src/services/reminder-recipients/reminder-recipient-dal.ts new file mode 100644 index 000000000..67988d4db --- /dev/null +++ b/backend/src/services/reminder-recipients/reminder-recipient-dal.ts @@ -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; + +export const reminderRecipientDALFactory = (db: TDbClient) => { + const reminderRecipientOrm = ormify(db, TableName.ReminderRecipient); + + return { ...reminderRecipientOrm }; +}; diff --git a/backend/src/services/reminder/reminder-dal.ts b/backend/src/services/reminder/reminder-dal.ts new file mode 100644 index 000000000..0e5d7b895 --- /dev/null +++ b/backend/src/services/reminder/reminder-dal.ts @@ -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; + +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(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.SecretV2, `${TableName.Reminder}.secretId`, `${TableName.SecretV2}.id`) + .leftJoin( + TableName.SecretFolder, + `${TableName.SecretV2}.folderId`, + `${TableName.SecretFolder}.id` + ) + .leftJoin( + TableName.Environment, + `${TableName.SecretFolder}.envId`, + `${TableName.Environment}.id` + ) + .leftJoin(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .leftJoin(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 + }; +}; diff --git a/backend/src/services/reminder/reminder-enums.ts b/backend/src/services/reminder/reminder-enums.ts new file mode 100644 index 000000000..c6650b593 --- /dev/null +++ b/backend/src/services/reminder/reminder-enums.ts @@ -0,0 +1,3 @@ +export enum ReminderType { + SECRETS = "secrets" +} diff --git a/backend/src/services/reminder/reminder-queue.ts b/backend/src/services/reminder/reminder-queue.ts new file mode 100644 index 000000000..4e31c8a6d --- /dev/null +++ b/backend/src/services/reminder/reminder-queue.ts @@ -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; + secretReminderRecipientsDAL: Pick; +}; + +export type TDailyReminderQueueServiceFactory = ReturnType; + +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()); + 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 + }; +}; diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts new file mode 100644 index 000000000..ddccbbf62 --- /dev/null +++ b/backend/src/services/reminder/reminder-service.ts @@ -0,0 +1,359 @@ +/* eslint-disable no-await-in-loop */ +import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; + +import { ActionProjectType, TableName } from "@app/db/schemas"; +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; + permissionService: Pick; + secretV2BridgeDAL: Pick; +}; + +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 => { + 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, + reminder + }: TCreateReminderDTO) => { + const secret = await secretV2BridgeDAL.findOneWithTags({ [`${TableName.SecretV2}.id` as "id"]: reminder.secretId }); + if (!secret) { + throw new BadRequestError({ message: `Secret ${reminder.secretId} not found` }); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secret.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); + + const response = await createReminderInternal({ + ...reminder, + projectId: secret.projectId + }); + return response; + }; + + const getReminder: TReminderServiceFactory["getReminder"] = async ({ + secretId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: { + secretId: string; + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + }) => { + const secret = await secretV2BridgeDAL.findOneWithTags({ [`${TableName.SecretV2}.id` as "id"]: secretId }); + if (!secret) { + throw new BadRequestError({ message: `Secret ${secretId} not found` }); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secret.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + 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 + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + secretId: string; + }) => { + const secret = await secretV2BridgeDAL.findOneWithTags({ [`${TableName.SecretV2}.id` as "id"]: secretId }); + if (!secret) { + throw new BadRequestError({ message: `Secret ${secretId} not found` }); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secret.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); + await reminderDAL.delete({ secretId }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secret.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, + deleteReminderBySecretId, + batchCreateReminders, + createReminderInternal + }; +}; diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts new file mode 100644 index 000000000..1f6a53ac7 --- /dev/null +++ b/backend/src/services/reminder/reminder-types.ts @@ -0,0 +1,102 @@ +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; + 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, reminder }: TCreateReminderDTO) => Promise<{ + id: string; + created: boolean; + }>; + + getReminder: ({ + secretId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: { + secretId: string; + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + }) => Promise<(TReminder & { recipients: string[] }) | null>; + + sendDailyReminders: () => Promise; + + deleteReminder: ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + secretId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + secretId: string; + }) => Promise; + + deleteReminderBySecretId: (secretId: string, projectId: string, tx?: Knex) => Promise; + + 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; + }>; +} diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 39926488f..bfaef5708 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -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; secretVersionDAL: Pick; secretVersionV2DAL: Pick; - secretDAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; @@ -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(); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 1047a09cb..4cbd1d783 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -490,6 +490,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; @@ -539,18 +540,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) => { @@ -573,7 +571,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")) @@ -835,6 +837,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, @@ -852,6 +892,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { countByFolderIds, findOne, find, - invalidateSecretCacheByProjectId + invalidateSecretCacheByProjectId, + findSecretsWithReminderRecipients }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index fae3a07d0..7aee91273 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -231,18 +231,7 @@ export const fnSecretBulkUpdate = async ({ const sanitizedInputSecrets = inputSecrets.map( ({ filter, - data: { - skipMultilineEncoding, - type, - key, - encryptedValue, - userId, - encryptedComment, - metadata, - secretMetadata, - reminderNote, - reminderRepeatDays - } + data: { skipMultilineEncoding, type, key, encryptedValue, userId, encryptedComment, metadata, secretMetadata } }) => ({ filter: { ...filter, folderId }, data: { @@ -252,9 +241,7 @@ export const fnSecretBulkUpdate = async ({ userId, encryptedComment, metadata: JSON.stringify(metadata || secretMetadata || []), - reminderNote, - encryptedValue, - reminderRepeatDays + encryptedValue } }) ); @@ -270,9 +257,7 @@ export const fnSecretBulkUpdate = async ({ encryptedComment, version, metadata, - reminderNote, encryptedValue, - reminderRepeatDays, id: secretId }) => ({ skipMultilineEncoding, @@ -282,9 +267,7 @@ export const fnSecretBulkUpdate = async ({ encryptedComment, version, metadata: metadata ? JSON.stringify(metadata) : [], - reminderNote, encryptedValue, - reminderRepeatDays, folderId, secretId, userActorId, @@ -407,6 +390,7 @@ export const fnSecretBulkDelete = async ({ secretQueueService, folderCommitService, secretVersionDAL, + projectId, commitChanges }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( @@ -419,11 +403,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 + ) ) ); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 32c7a3f71..2fa0ffe9b 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -39,6 +39,7 @@ import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder 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"; @@ -115,6 +116,7 @@ type TSecretV2BridgeServiceFactoryDep = { snapshotService: Pick; resourceMetadataDAL: Pick; keyStore: Pick; + reminderService: Pick; }; export type TSecretV2BridgeServiceFactory = ReturnType; @@ -139,7 +141,8 @@ export const secretV2BridgeServiceFactory = ({ secretApprovalRequestSecretDAL, kmsService, resourceMetadataDAL, - keyStore + keyStore, + reminderService }: TSecretV2BridgeServiceFactoryDep) => { const $validateSecretReferences = async ( projectId: string, @@ -311,7 +314,6 @@ export const secretV2BridgeServiceFactory = ({ { version: 1, type, - reminderRepeatDays: inputSecretData.secretReminderRepeatDays, encryptedComment: setKnexStringValue( inputSecretData.secretComment, (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob @@ -319,7 +321,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, @@ -345,6 +346,20 @@ export const secretV2BridgeServiceFactory = ({ return createdSecret; }); + if (inputSecret.secretReminderRepeatDays) { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + reminder: { + secretId: secret.id, + message: inputSecret.secretReminderNote, + repeatDays: inputSecret.secretReminderRepeatDays + } + }); + } + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); @@ -521,12 +536,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, @@ -547,19 +560,20 @@ 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, + reminder: { + secretId: secret.id, + message: inputSecret.secretReminderNote, + repeatDays: inputSecret.secretReminderRepeatDays, + recipients: inputSecret.secretReminderRecipients + } + }); + } await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { @@ -1930,12 +1944,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, @@ -2601,9 +2613,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, @@ -2997,6 +3007,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, @@ -3016,6 +3031,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsByFolderMappings, getSecretById, getAccessibleSecrets, - getSecretVersionsByIds + getSecretVersionsByIds, + findSecretIdsByFolderIdAndKeys }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 48b2e0ef6..fc7d468ff 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -249,6 +249,7 @@ export type TCreateSecretReminderDTO = { export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + projectId: string; }; export type TBackFillSecretReferencesDTO = TProjectPermission; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index dc41d129f..a540d3435 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -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; @@ -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 }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index c7b93cd08..3b97f5891 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -18,11 +18,9 @@ import { ProjectPermissionSecretActions } from "@app/ee/services/permission/proj import { getConfig } from "@app/lib/config/env"; import { buildSecretBlindIndexFromName } from "@app/lib/crypto"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; -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, @@ -34,6 +32,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"; @@ -745,7 +744,8 @@ export const fnSecretBulkDelete = async ({ tx, actorId, secretDAL, - secretQueueService + secretQueueService, + projectId }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretBlindIndex }) => ({ @@ -761,7 +761,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 + ) ) ); @@ -1228,14 +1231,14 @@ export const decryptSecretWithBot = ( type TFnDeleteProjectSecretReminders = { secretDAL: Pick; secretV2BridgeDAL: Pick; - queueService: Pick; + reminderService: Pick; projectBotService: Pick; folderDAL: Pick; }; 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); @@ -1250,23 +1253,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); } } }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 4a94d3d38..689e19673 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -19,7 +19,6 @@ import { TSnapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/sn import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; -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"; @@ -40,7 +39,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"; @@ -49,12 +47,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"; @@ -92,7 +90,6 @@ type TSecretQueueFactoryDep = { projectKeyDAL: Pick; projectMembershipDAL: Pick; smtpService: TSmtpService; - orgDAL: Pick; secretVersionDAL: TSecretVersionDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; secretTagDAL: TSecretTagDALFactory; @@ -112,11 +109,8 @@ type TSecretQueueFactoryDep = { projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; folderCommitService: Pick; - secretReminderRecipientsDAL: Pick< - TSecretReminderRecipientsDALFactory, - "delete" | "findUsersBySecretId" | "insertMany" | "transaction" - >; secretSyncQueue: Pick; + reminderService: Pick; }; export type TGetSecrets = { @@ -154,7 +148,6 @@ export const secretQueueFactory = ({ userDAL, webhookDAL, projectEnvDAL, - orgDAL, smtpService, projectDAL, projectBotDAL, @@ -177,9 +170,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", { @@ -189,19 +182,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 => { @@ -241,11 +223,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", @@ -260,38 +240,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({ @@ -304,55 +259,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, @@ -1118,62 +1048,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) => { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 10527938f..f776b4527 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -46,6 +46,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"; @@ -129,6 +130,7 @@ type TSecretServiceFactoryDep = { "insertMany" | "insertApprovalSecretTags" >; licenseService: Pick; + reminderService: Pick; }; export type TSecretServiceFactory = ReturnType; @@ -151,7 +153,8 @@ export const secretServiceFactory = ({ secretApprovalRequestSecretDAL, secretV2BridgeService, secretApprovalRequestService, - licenseService + licenseService, + reminderService }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist @@ -551,7 +554,8 @@ export const secretServiceFactory = ({ await secretQueueService.removeSecretReminder( { repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id + secretId: secret.id, + projectId }, tx ); @@ -1082,7 +1086,8 @@ export const secretServiceFactory = ({ await secretQueueService.removeSecretReminder( { repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id + secretId: secret.id, + projectId }, tx ); @@ -1672,8 +1677,6 @@ export const secretServiceFactory = ({ secretComment, secretValue, tagIds, - reminderNote: secretReminderNote, - reminderRepeatDays: secretReminderRepeatDays, secretMetadata } ] @@ -1855,9 +1858,6 @@ export const secretServiceFactory = ({ secretComment, secretValue, tagIds, - reminderNote: secretReminderNote, - reminderRepeatDays: secretReminderRepeatDays, - secretReminderRecipients, secretMetadata } ] @@ -1866,9 +1866,6 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Approval as const, approval }; } const secret = await secretV2BridgeService.updateSecret({ - secretReminderRepeatDays, - secretReminderNote, - secretReminderRecipients, skipMultilineEncoding, tagIds, secretComment, @@ -1886,6 +1883,21 @@ export const secretServiceFactory = ({ secretValue, secretMetadata }); + + if (secretReminderRepeatDays) { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + reminder: { + secretId: secret.id, + message: secretReminderNote, + repeatDays: secretReminderRepeatDays, + recipients: secretReminderRecipients + } + }); + } return { type: SecretProtectionType.Direct as const, secret }; } @@ -2318,6 +2330,29 @@ export const secretServiceFactory = ({ secrets: inputSecrets, mode }); + + await Promise.all( + inputSecrets + .filter((el) => el.secretReminderRepeatDays) + .map(async (secret) => { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + 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 }; } diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 6a2a2b7e3..cd341a8b5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -312,6 +312,7 @@ export type TUpdateManySecretRawDTO = Omit & { secretMetadata?: ResourceMetadataDTO; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretPath?: string; }[]; }; @@ -412,6 +413,7 @@ export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; projectId: string; + secretReminderRecipients: string[]; deleteRecipients?: boolean; }; @@ -419,6 +421,7 @@ export type TCreateSecretReminderDTO = { export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + projectId: string; deleteRecipients?: boolean; }; diff --git a/frontend/src/components/v2/DatePicker/DatePicker.tsx b/frontend/src/components/v2/DatePicker/DatePicker.tsx index a4af1afd9..115b0ad3e 100644 --- a/frontend/src/components/v2/DatePicker/DatePicker.tsx +++ b/frontend/src/components/v2/DatePicker/DatePicker.tsx @@ -20,6 +20,7 @@ export type DatePickerProps = Omit & { popUpProps: PopoverProps; popUpContentProps: PopoverContentProps; dateFormat?: "PPP" | "PP" | "P"; // extend as needed + hideTime?: boolean; buttonClassName?: string; timezone?: Timezone; }; @@ -55,6 +56,7 @@ export const DatePicker = ({ popUpProps, popUpContentProps, dateFormat = "PPP", + hideTime = false, buttonClassName, timezone, ...props @@ -124,14 +126,16 @@ export const DatePicker = ({ }} /> -
- -
+ {!hideTime && ( +
+ +
+ )} ); diff --git a/frontend/src/hooks/api/reminders/index.tsx b/frontend/src/hooks/api/reminders/index.tsx new file mode 100644 index 000000000..b5f16bd3f --- /dev/null +++ b/frontend/src/hooks/api/reminders/index.tsx @@ -0,0 +1 @@ +export { useCreateReminder, useDeleteReminder, useGetReminder } from "./queries"; diff --git a/frontend/src/hooks/api/reminders/queries.tsx b/frontend/src/hooks/api/reminders/queries.tsx new file mode 100644 index 000000000..68c00913d --- /dev/null +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -0,0 +1,60 @@ +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) => ["get-reminder", secretId] as const +}; + +export const useCreateReminder = (secretId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { + const { data } = await apiRequest.post<{ reminder: Reminder }>( + `/api/v1/reminders/secrets/${secretId}`, + { + message, + repeatDays, + nextReminderDate, + recipients + } + ); + return data.reminder; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId) }); + } + }); +}; + +export const useDeleteReminder = (secretId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.delete<{ reminder: Reminder }>( + `/api/v1/reminders/secrets/${secretId}` + ); + return data.reminder; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId) }); + } + }); +}; + +export const useGetReminder = (secretId: string) => { + return useQuery({ + queryKey: reminderKeys.getReminder(secretId), + queryFn: async () => { + const { data } = await apiRequest.get<{ reminder: Reminder }>( + `/api/v1/reminders/secrets/${secretId}` + ); + return data.reminder; + }, + enabled: Boolean(secretId) + }); +}; diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts new file mode 100644 index 000000000..245805aea --- /dev/null +++ b/frontend/src/hooks/api/reminders/types.ts @@ -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; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index d5f1e146c..3b9db7bed 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -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; -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); + const { mutateAsync: deleteReminder } = useDeleteReminder(secretId); + + // Form setup + const form = useForm({ + 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({ - 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) + }); }; - 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 ( -
-
-
- ( - <> - - setValue("days", parseInt(el.target.value, 10))} - type="number" - placeholder="31" - defaultValue={repeatDays || undefined} - value={field.value || undefined} - /> - -
- A reminder will be sent every{" "} - {field.value && field.value > 1 ? `${field.value} days` : "day"} -
- - )} - /> -
- - -