From 0efc314f33483e5d41b882c3a62f7c84b4ef4a77 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 4 Jul 2025 09:47:36 -0300 Subject: [PATCH] feat(secret-reminders): rework secret reminders logic --- backend/e2e-test/mocks/queue.ts | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 12 + .../20250701202824_add-reminder-table.ts | 42 ++ backend/src/db/schemas/models.ts | 6 +- .../src/db/schemas/reminders-recipients.ts | 20 + backend/src/db/schemas/reminders.ts | 22 + backend/src/queue/queue-service.ts | 29 +- backend/src/server/routes/index.ts | 45 +- backend/src/server/routes/v1/index.ts | 3 + .../src/server/routes/v1/reminder-router.ts | 111 ++++ backend/src/services/org/org-service.ts | 10 +- .../src/services/project/project-service.ts | 10 +- .../reminder-recipients/reminder-dal.ts | 11 + .../reminder-recipients/reminder-types.ts | 6 + backend/src/services/reminder/reminder-dal.ts | 135 +++++ .../src/services/reminder/reminder-queue.ts | 190 +++++++ .../src/services/reminder/reminder-service.ts | 332 +++++++++++ .../src/services/reminder/reminder-types.ts | 44 ++ .../resource-cleanup-queue.ts | 4 - .../secret-v2-bridge/secret-v2-bridge-dal.ts | 63 ++- .../secret-v2-bridge/secret-v2-bridge-fns.ts | 23 +- .../secret-v2-bridge-service.ts | 64 ++- backend/src/services/secret/secret-dal.ts | 91 ---- backend/src/services/secret/secret-fns.ts | 19 +- backend/src/services/secret/secret-queue.ts | 206 ++----- backend/src/services/secret/secret-service.ts | 56 +- backend/src/services/secret/secret-types.ts | 4 +- .../components/v2/DatePicker/DatePicker.tsx | 20 +- frontend/src/hooks/api/reminders/index.tsx | 3 + frontend/src/hooks/api/reminders/queries.tsx | 62 +++ frontend/src/hooks/api/reminders/types.ts | 21 + .../SecretListView/CreateReminderForm.tsx | 515 +++++++++++++----- .../SecretListView/SecretDetailSidebar.tsx | 62 +-- 34 files changed, 1709 insertions(+), 535 deletions(-) create mode 100644 backend/src/db/migrations/20250701202824_add-reminder-table.ts create mode 100644 backend/src/db/schemas/reminders-recipients.ts create mode 100644 backend/src/db/schemas/reminders.ts create mode 100644 backend/src/server/routes/v1/reminder-router.ts create mode 100644 backend/src/services/reminder-recipients/reminder-dal.ts create mode 100644 backend/src/services/reminder-recipients/reminder-types.ts create mode 100644 backend/src/services/reminder/reminder-dal.ts create mode 100644 backend/src/services/reminder/reminder-queue.ts create mode 100644 backend/src/services/reminder/reminder-service.ts create mode 100644 backend/src/services/reminder/reminder-types.ts create mode 100644 frontend/src/hooks/api/reminders/index.tsx create mode 100644 frontend/src/hooks/api/reminders/queries.tsx create mode 100644 frontend/src/hooks/api/reminders/types.ts 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..c3fb6b69c 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-service"; 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..d140e0d82 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.Reminders]: KnexOriginal.CompositeTableType; + [TableName.RemindersRecipients]: 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..c554545f1 --- /dev/null +++ b/backend/src/db/migrations/20250701202824_add-reminder-table.ts @@ -0,0 +1,42 @@ +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.Reminders))) { + await knex.schema.createTable(TableName.Reminders, (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").nullable(); + t.integer("repeatDays").nullable(); + t.timestamp("nextReminderDate").notNullable(); + t.timestamps(true, true, true); + t.index("secretId"); + }); + } + + if (!(await knex.schema.hasTable(TableName.RemindersRecipients))) { + await knex.schema.createTable(TableName.RemindersRecipients, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("reminderId").notNullable(); + t.foreign("reminderId").references("id").inTable(TableName.Reminders).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"); + }); + } + + await createOnUpdateTrigger(knex, TableName.Reminders); + await createOnUpdateTrigger(knex, TableName.RemindersRecipients); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.Reminders); + await knex.schema.dropTableIfExists(TableName.RemindersRecipients); + await dropOnUpdateTrigger(knex, TableName.Reminders); + await dropOnUpdateTrigger(knex, TableName.RemindersRecipients); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 75d36833b..cf829c78a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -172,7 +172,11 @@ export enum TableName { SecretScanningResource = "secret_scanning_resources", SecretScanningScan = "secret_scanning_scans", SecretScanningFinding = "secret_scanning_findings", - SecretScanningConfig = "secret_scanning_configs" + SecretScanningConfig = "secret_scanning_configs", + + // reminders + Reminders = "reminders", + RemindersRecipients = "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/queue/queue-service.ts b/backend/src/queue/queue-service.ts index a8d0d10a6..708efa8c8 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -63,7 +63,9 @@ export enum QueueName { FolderTreeCheckpoint = "folder-tree-checkpoint", InvalidateCache = "invalidate-cache", SecretScanningV2 = "secret-scanning-v2", - TelemetryAggregatedEvents = "telemetry-aggregated-events" + TelemetryAggregatedEvents = "telemetry-aggregated-events", + DailyReminders = "daily-reminders", + SecretReminderMigration = "secret-reminder-migration" } export enum QueueJobs { @@ -103,7 +105,9 @@ export enum QueueJobs { SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", - TelemetryAggregatedEvents = "telemetry-aggregated-events" + TelemetryAggregatedEvents = "telemetry-aggregated-events", + DailyReminders = "daily-reminders", + SecretReminderMigration = "secret-reminder-migration" } export type TQueueJobTypes = { @@ -290,6 +294,14 @@ export type TQueueJobTypes = { caType: CaType; }; }; + [QueueName.DailyReminders]: { + name: QueueJobs.DailyReminders; + payload: undefined; + }; + [QueueName.SecretReminderMigration]: { + name: QueueJobs.SecretReminderMigration; + payload: undefined; + }; [QueueName.PkiSubscriber]: { name: QueueJobs.PkiSubscriberDailyAutoRenewal; payload: undefined; @@ -389,6 +401,11 @@ export type TQueueServiceFactory = { startOffset?: number, endOffset?: number ) => Promise<{ key: string; name: string; id: string | null }[]>; + getDelayedJobs: ( + name: QueueName, + startOffset?: number, + endOffset?: number + ) => Promise<{ delay: number; timestamp: number; repeatJobKey?: string }[]>; }; export const queueServiceFactory = ( @@ -535,6 +552,13 @@ export const queueServiceFactory = ( return q.getRepeatableJobs(startOffset, endOffset); }; + const getDelayedJobs: TQueueServiceFactory["getDelayedJobs"] = (name, startOffset, endOffset) => { + const q = queueContainer[name]; + if (!q) throw new Error(`Queue '${name}' not initialized`); + + return q.getDelayed(startOffset, endOffset); + }; + const stopRepeatableJobByJobId: TQueueServiceFactory["stopRepeatableJobByJobId"] = async (name, jobId) => { const q = queueContainer[name]; const job = await q.getJob(jobId); @@ -581,6 +605,7 @@ export const queueServiceFactory = ( stopJobById, stopJobByIdPg, getRepeatableJobs, + getDelayedJobs, startPg, queuePg, schedulePg diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2469bdfb2..075b6a853 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -245,6 +245,10 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { reminderDALFactory } from "@app/services/reminder/reminder-dal"; +import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue"; +import { reminderServiceFactory } from "@app/services/reminder/reminder-service"; +import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-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"; @@ -368,6 +372,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); @@ -731,9 +738,16 @@ export const registerRoutes = async ( const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); + const reminderService = reminderServiceFactory({ + reminderDAL, + reminderRecipientDAL, + smtpService, + projectMembershipDAL, + permissionService + }); + const orgService = orgServiceFactory({ userAliasDAL, - queueService, identityMetadataDAL, secretDAL, secretV2BridgeDAL, @@ -759,7 +773,8 @@ export const registerRoutes = async ( orgBotDAL, oidcConfigDAL, loginService, - projectBotService + projectBotService, + reminderService }); const signupService = authSignupServiceFactory({ tokenService, @@ -1057,7 +1072,6 @@ export const registerRoutes = async ( secretImportDAL, projectEnvDAL, webhookDAL, - orgDAL, auditLogService, userDAL, projectMembershipDAL, @@ -1079,11 +1093,11 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, - secretReminderRecipientsDAL, orgService, resourceMetadataDAL, folderCommitService, - secretSyncQueue + secretSyncQueue, + reminderService }); const projectService = projectServiceFactory({ @@ -1092,7 +1106,6 @@ export const registerRoutes = async ( projectSshConfigDAL, secretDAL, secretV2BridgeDAL, - queueService, projectQueue: projectQueueService, projectBotService, identityProjectDAL, @@ -1129,7 +1142,8 @@ export const registerRoutes = async ( microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, - smtpService + smtpService, + reminderService }); const projectEnvService = projectEnvServiceFactory({ @@ -1228,6 +1242,7 @@ export const registerRoutes = async ( kmsService, snapshotService, resourceMetadataDAL, + reminderService, keyStore }); @@ -1281,7 +1296,8 @@ export const registerRoutes = async ( secretApprovalRequestSecretDAL, secretV2BridgeService, secretApprovalRequestService, - licenseService + licenseService, + reminderService }); const secretSharingService = secretSharingServiceFactory({ @@ -1608,7 +1624,6 @@ export const registerRoutes = async ( auditLogDAL, queueService, secretVersionDAL, - secretDAL, secretFolderVersionDAL: folderVersionDAL, snapshotDAL, identityAccessTokenDAL, @@ -1618,6 +1633,13 @@ export const registerRoutes = async ( serviceTokenService }); + const dailyReminderQueueService = dailyReminderQueueServiceFactory({ + reminderService, + queueService, + secretDAL: secretV2BridgeDAL, + secretReminderRecipientsDAL + }); + const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ queueService, pkiAlertService @@ -1910,6 +1932,8 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); + await dailyReminderQueueService.startDailyRemindersJob(); + await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); await kmsService.startService(); @@ -2020,7 +2044,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..2981763c5 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 { registerReminderRouter } from "./reminder-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; import { registerSecretRequestsRouter } from "./secret-requests-router"; @@ -172,4 +173,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { }, { prefix: "/secret-syncs" } ); + + await server.register(registerReminderRouter, { prefix: "/reminders" }); }; diff --git a/backend/src/server/routes/v1/reminder-router.ts b/backend/src/server/routes/v1/reminder-router.ts new file mode 100644 index 000000000..96396172a --- /dev/null +++ b/backend/src/server/routes/v1/reminder-router.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import { RemindersSchema } from "@app/db/schemas/reminders"; +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 registerReminderRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/:projectId/reminder", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().uuid() + }), + body: z.object({ + message: z.string().trim(), + repeatDays: z.number().min(1).nullable().optional(), + nextReminderDate: z.string().datetime().nullable().optional(), + secretId: z.string().uuid(), + recipients: z.string().array().optional() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.reminder.createReminder({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectId: req.params.projectId, + reminder: req.body + }); + return { message: "Successfully created reminder" }; + } + }); + + server.route({ + url: "/:projectId/reminder/:secretId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().uuid(), + 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, + projectId: req.params.projectId + }); + return { reminder }; + } + }); + + server.route({ + url: "/:projectId/reminder/:secretId", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().uuid(), + 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, + projectId: req.params.projectId + }); + return { message: "Successfully deleted reminder" }; + } + }); +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index dd16cbc3c..861038d5a 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -46,7 +46,6 @@ import { import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; -import { TQueueServiceFactory } 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"; @@ -64,6 +63,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-service"; import { TSecretDALFactory } from "../secret/secret-dal"; import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -123,8 +123,8 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; - queueService: Pick; loginService: Pick; + reminderService: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -156,8 +156,8 @@ export const orgServiceFactory = ({ projectUserMembershipRoleDAL, identityMetadataDAL, projectBotService, - queueService, - loginService + loginService, + reminderService }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -599,7 +599,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 0aaabbdf4..1dde73839 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -32,7 +32,6 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; -import { TQueueServiceFactory } from "@app/queue"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -60,6 +59,7 @@ import { TProjectMembershipDALFactory } from "../project-membership/project-memb import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { getPredefinedRoles } from "../project-role/project-role-fns"; +import { TReminderServiceFactory } from "../reminder/reminder-service"; import { TSecretDALFactory } from "../secret/secret-dal"; import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -162,7 +162,6 @@ type TProjectServiceFactoryDep = { permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; - queueService: Pick; smtpService: Pick; orgDAL: Pick; keyStore: Pick; @@ -179,6 +178,7 @@ type TProjectServiceFactoryDep = { | "createCipherPairWithDataKey" >; projectTemplateService: TProjectTemplateServiceFactory; + reminderService: Pick; }; export type TProjectServiceFactory = ReturnType; @@ -191,7 +191,6 @@ export const projectServiceFactory = ({ projectQueue, projectKeyDAL, permissionService, - queueService, projectBotService, orgDAL, userDAL, @@ -226,7 +225,8 @@ export const projectServiceFactory = ({ microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, - smtpService + smtpService, + reminderService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -555,7 +555,7 @@ export const projectServiceFactory = ({ await fnDeleteProjectSecretReminders(project.id, { secretDAL, secretV2BridgeDAL, - queueService, + reminderService, projectBotService, folderDAL }); diff --git a/backend/src/services/reminder-recipients/reminder-dal.ts b/backend/src/services/reminder-recipients/reminder-dal.ts new file mode 100644 index 000000000..251ddd98e --- /dev/null +++ b/backend/src/services/reminder-recipients/reminder-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.RemindersRecipients); + + return { ...reminderRecipientOrm }; +}; diff --git a/backend/src/services/reminder-recipients/reminder-types.ts b/backend/src/services/reminder-recipients/reminder-types.ts new file mode 100644 index 000000000..194b19f9c --- /dev/null +++ b/backend/src/services/reminder-recipients/reminder-types.ts @@ -0,0 +1,6 @@ +export type TReminderRecipient = { + id: string; + reminderId: string; + userId: string; + createdAt: Date; +}; diff --git a/backend/src/services/reminder/reminder-dal.ts b/backend/src/services/reminder/reminder-dal.ts new file mode 100644 index 000000000..14d4d8cf8 --- /dev/null +++ b/backend/src/services/reminder/reminder-dal.ts @@ -0,0 +1,135 @@ +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.Reminders); + + const getTodayDateRange = () => { + const now = new Date(); + const startOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 23, 59, 59, 999)); + + return { startOfDay, endOfDay }; + }; + + const findSecretDailyReminders = async (tx?: Knex) => { + const { startOfDay, endOfDay } = getTodayDateRange(); + + const rawReminders = await (tx || db)(TableName.Reminders) + .whereBetween("nextReminderDate", [startOfDay, endOfDay]) + .leftJoin( + TableName.RemindersRecipients, + `${TableName.Reminders}.id`, + `${TableName.RemindersRecipients}.reminderId` + ) + .leftJoin(TableName.Users, `${TableName.RemindersRecipients}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.SecretV2, `${TableName.Reminders}.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.Reminders)) + .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.Reminders) + .where("nextReminderDate", ">=", startOfDay) + .where("nextReminderDate", "<=", futureDate) + .orderBy("nextReminderDate", "asc") + .leftJoin( + TableName.RemindersRecipients, + `${TableName.Reminders}.id`, + `${TableName.RemindersRecipients}.reminderId` + ) + .select(selectAllTableCols(TableName.Reminders)) + .select(db.ref("userId").withSchema(TableName.RemindersRecipients)); + return reminders; + }; + + const findSecretReminder = async (secretId: string, tx?: Knex) => { + const rawReminders = await (tx || db)(TableName.Reminders) + .where(`${TableName.Reminders}.secretId`, secretId) + .leftJoin( + TableName.RemindersRecipients, + `${TableName.Reminders}.id`, + `${TableName.RemindersRecipients}.reminderId` + ) + .select(selectAllTableCols(TableName.Reminders)) + .select(db.ref("userId").withSchema(TableName.RemindersRecipients)); + 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-queue.ts b/backend/src/services/reminder/reminder-queue.ts new file mode 100644 index 000000000..2439c34d8 --- /dev/null +++ b/backend/src/services/reminder/reminder-queue.ts @@ -0,0 +1,190 @@ +/* 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-service"; + +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 + }); + } + 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 reminderService.batchCreateReminders( + secretsWithReminder.map((secret) => { + const delayedJob = reminderDelayedJobs.get(secret.id); + 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 || [] + }; + }) + ); + + await secretReminderRecipientsDAL.delete({ $in: { secretId: secretsWithReminder.map((s) => s.id) } }); + + 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}: resource cleanup failed`); + }); + + queueService.listen(QueueName.SecretReminderMigration, "failed", (_, err) => { + logger.error(err, `${QueueName.SecretReminderMigration}: resource cleanup 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..b9e497d96 --- /dev/null +++ b/backend/src/services/reminder/reminder-service.ts @@ -0,0 +1,332 @@ +/* eslint-disable no-await-in-loop */ +import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TReminderRecipientDALFactory } from "../reminder-recipients/reminder-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TReminderDALFactory } from "./reminder-dal"; +import { TBatchCreateReminderDTO, TCreateReminderDTO } from "./reminder-types"; + +type TReminderServiceFactoryDep = { + reminderDAL: TReminderDALFactory; + reminderRecipientDAL: TReminderRecipientDALFactory; + smtpService: TSmtpService; + projectMembershipDAL: Pick; + permissionService: Pick; +}; + +export type TReminderServiceFactory = ReturnType; + +export const reminderServiceFactory = ({ + reminderDAL, + reminderRecipientDAL, + smtpService, + projectMembershipDAL, + permissionService +}: TReminderServiceFactoryDep) => { + 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 = async ({ + secretId, + message, + repeatDays, + nextReminderDate: nextReminderDateInput, + recipients + }: { + secretId?: string; + message?: string | null; + repeatDays?: number | null; + nextReminderDate?: string | null; + recipients?: string[] | null; + }) => { + 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); + + return { id: reminderId, created: !existingReminder }; + }; + + const createReminder = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + reminder + }: TCreateReminderDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); + + const response = await createReminderInternal(reminder); + return response; + }; + + const getReminder = async ({ + secretId, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: { + secretId: string; + projectId: string; + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSub.Secrets + ); + const reminder = await reminderDAL.findSecretReminder(secretId); + return reminder; + }; + + const sendDailyReminders = async () => { + const remindersToSend = await reminderDAL.findSecretDailyReminders(); + + for (const reminder of remindersToSend) { + try { + 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) }); + } else { + await reminderDAL.deleteById(reminder.id); + } + } catch (error) { + logger.error( + error, + `Failed to send reminder to recipients ${reminder.recipients.map((r) => r.email).join(", ")}` + ); + } + } + }; + + const deleteReminder = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + secretId, + projectId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + secretId: string; + projectId: string; + }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); + await reminderDAL.delete({ secretId }); + }; + + const removeReminderRecipients = async (secretId: string, tx?: Knex) => { + const reminder = await reminderDAL.findOne({ secretId }, tx); + if (!reminder) { + return; + } + await reminderRecipientDAL.delete({ reminderId: reminder.id }, tx); + }; + + const deleteReminderBySecretId = async (secretId: string, tx?: Knex) => { + await reminderDAL.delete({ secretId }, tx); + }; + + const batchCreateReminders = async (remindersData: TBatchCreateReminderDTO) => { + if (!remindersData || remindersData.length === 0) { + return { created: 0, reminderIds: [] }; + } + + const processedReminders = remindersData.map( + ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients }) => { + 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)] : [] + }; + } + ); + + const newReminders = await reminderDAL.insertMany( + processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ + secretId, + message, + repeatDays, + nextReminderDate + })) + ); + + 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); + } + + return { + created: newReminders.length, + reminderIds: newReminders.map((r) => r.id) + }; + }; + + return { + createReminder, + getReminder, + sendDailyReminders, + deleteReminder, + removeReminderRecipients, + 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..ea6bd1d63 --- /dev/null +++ b/backend/src/services/reminder/reminder-types.ts @@ -0,0 +1,44 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type TReminder = { + id: string; + secretId?: string; + message?: string; + repeatDays?: number; + nextReminderDate?: Date; + createdAt: Date; +}; + +export type TCreateReminderDTO = { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + reminder: { + secretId?: string; + message?: string | null; + repeatDays?: number | null; + nextReminderDate?: string | null; + recipients?: string[] | null; + }; +}; + +export type TCreateSecretReminderDTO = { + secretName: string; + projectId: string; + environment: string; + secretPath: 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; +}[]; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 32f180636..77cd6e603 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -5,7 +5,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 { 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"; @@ -18,7 +17,6 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { identityUniversalAuthClientSecretDAL: Pick; secretVersionDAL: Pick; secretVersionV2DAL: Pick; - secretDAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; secretSharingDAL: Pick; @@ -34,7 +32,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ snapshotDAL, secretVersionDAL, secretFolderVersionDAL, - secretDAL, identityAccessTokenDAL, secretSharingDAL, secretVersionV2DAL, @@ -43,7 +40,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); - await secretDAL.pruneSecretReminders(queueService); await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); 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 cd2773172..40fd0bbf8 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 @@ -513,18 +513,19 @@ 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.Reminders, `${TableName.SecretV2}.id`, `${TableName.Reminders}.secretId`) + .leftJoin( + TableName.RemindersRecipients, + `${TableName.Reminders}.id`, + `${TableName.RemindersRecipients}.reminderId` + ) + .leftJoin(TableName.Users, `${TableName.RemindersRecipients}.userId`, `${TableName.Users}.id`) .where((qb) => { if (filters?.metadataFilter && filters.metadataFilter.length > 0) { filters.metadataFilter.forEach((meta) => { @@ -547,7 +548,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { }) as rank` ) ) - .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("id").withSchema(TableName.Reminders).as("reminderId")) + .select(db.ref("message").withSchema(TableName.Reminders).as("reminderNote")) + .select(db.ref("repeatDays").withSchema(TableName.Reminders).as("reminderRepeatDays")) + .select(db.ref("nextReminderDate").withSchema(TableName.Reminders).as("nextReminderDate")) + .select(db.ref("id").withSchema(TableName.RemindersRecipients).as("reminderRecipientId")) .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) @@ -809,6 +814,47 @@ 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.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("userId").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientUserId")); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el) + }), + childrenMapper: [ + { + key: "reminderRecipientUserId", + label: "recipients" as const, + mapper: ({ reminderRecipientUserId }) => reminderRecipientUserId + } + ] + }); + + return data; + } catch (error) { + throw new DatabaseError({ error, name: "FindSecretsWithReminderRecipients" }); + } + }; + return { ...secretOrm, update, @@ -826,6 +872,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 01718d570..0d366ea1e 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 @@ -223,20 +223,7 @@ export const fnSecretBulkUpdate = async ({ const actorType = actor?.type || ActorType.PLATFORM; const sanitizedInputSecrets = inputSecrets.map( - ({ - filter, - data: { - skipMultilineEncoding, - type, - key, - encryptedValue, - userId, - encryptedComment, - metadata, - reminderNote, - reminderRepeatDays - } - }) => ({ + ({ filter, data: { skipMultilineEncoding, type, key, encryptedValue, userId, encryptedComment, metadata } }) => ({ filter: { ...filter, folderId }, data: { skipMultilineEncoding, @@ -245,9 +232,7 @@ export const fnSecretBulkUpdate = async ({ userId, encryptedComment, metadata, - reminderNote, - encryptedValue, - reminderRepeatDays + encryptedValue } }) ); @@ -263,9 +248,7 @@ export const fnSecretBulkUpdate = async ({ encryptedComment, version, metadata, - reminderNote, encryptedValue, - reminderRepeatDays, id: secretId }) => ({ skipMultilineEncoding, @@ -275,9 +258,7 @@ export const fnSecretBulkUpdate = async ({ encryptedComment, version, metadata: metadata ? JSON.stringify(metadata) : [], - reminderNote, encryptedValue, - reminderRepeatDays, folderId, secretId, userActorId, 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 71180772f..c66d1aea2 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 @@ -32,6 +32,7 @@ import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-serv import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TReminderServiceFactory } from "../reminder/reminder-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TSecretQueueFactory } from "../secret/secret-queue"; import { TGetASecretByIdDTO } from "../secret/secret-types"; @@ -108,6 +109,7 @@ type TSecretV2BridgeServiceFactoryDep = { snapshotService: Pick; resourceMetadataDAL: Pick; keyStore: Pick; + reminderService: Pick; }; export type TSecretV2BridgeServiceFactory = ReturnType; @@ -132,7 +134,8 @@ export const secretV2BridgeServiceFactory = ({ secretApprovalRequestSecretDAL, kmsService, resourceMetadataDAL, - keyStore + keyStore, + reminderService }: TSecretV2BridgeServiceFactoryDep) => { const $validateSecretReferences = async ( projectId: string, @@ -303,7 +306,6 @@ export const secretV2BridgeServiceFactory = ({ { version: 1, type, - reminderRepeatDays: inputSecretData.secretReminderRepeatDays, encryptedComment: setKnexStringValue( inputSecretData.secretComment, (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob @@ -311,7 +313,6 @@ export const secretV2BridgeServiceFactory = ({ encryptedValue: inputSecretData.secretValue ? secretManagerEncryptor({ plainText: Buffer.from(inputSecretData.secretValue) }).cipherTextBlob : undefined, - reminderNote: inputSecretData.secretReminderNote, skipMultilineEncoding: inputSecretData.skipMultilineEncoding, key: secretName, userId: inputSecret.type === SecretType.Personal ? actorId : null, @@ -337,6 +338,21 @@ export const secretV2BridgeServiceFactory = ({ return createdSecret; }); + if (inputSecret.secretReminderRepeatDays) { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + reminder: { + secretId: secret.id, + message: inputSecret.secretReminderNote, + repeatDays: inputSecret.secretReminderRepeatDays + } + }); + } + await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); @@ -512,12 +528,10 @@ export const secretV2BridgeServiceFactory = ({ { filter: { id: secretId }, data: { - reminderRepeatDays: inputSecret.secretReminderRepeatDays, encryptedComment: setKnexStringValue( inputSecret.secretComment, (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), - reminderNote: inputSecret.secretReminderNote, skipMultilineEncoding: inputSecret.skipMultilineEncoding, key: inputSecret.newSecretName || secretName, tags: inputSecret.tagIds, @@ -538,19 +552,21 @@ export const secretV2BridgeServiceFactory = ({ tx }) ); - await secretQueueService.handleSecretReminder({ - newSecret: { - id: updatedSecret[0].id, - ...inputSecret - }, - oldSecret: { - id: secret.id, - secretReminderNote: secret.reminderNote, - secretReminderRepeatDays: secret.reminderRepeatDays, - secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id) - }, - projectId - }); + if (inputSecret.secretReminderRepeatDays) { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + reminder: { + secretId: secret.id, + message: inputSecret.secretReminderNote, + repeatDays: inputSecret.secretReminderRepeatDays, + recipients: inputSecret.secretReminderRecipients + } + }); + } await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { @@ -1868,12 +1884,10 @@ export const secretV2BridgeServiceFactory = ({ return { filter: { id: originalSecret.id, type: SecretType.Shared }, data: { - reminderRepeatDays: el.secretReminderRepeatDays, encryptedComment: setKnexStringValue( el.secretComment, (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), - reminderNote: el.secretReminderNote, skipMultilineEncoding: el.skipMultilineEncoding, key: el.newSecretName || el.secretKey, tags: el.tagIds, @@ -2522,9 +2536,7 @@ export const secretV2BridgeServiceFactory = ({ key: doc.key, encryptedComment: doc.encryptedComment, skipMultilineEncoding: doc.skipMultilineEncoding, - reminderNote: doc.reminderNote, secretMetadata: doc.secretMetadata, - reminderRepeatDays: doc.reminderRepeatDays, ...(doc.encryptedValue ? { encryptedValue: doc.encryptedValue, @@ -2914,6 +2926,11 @@ export const secretV2BridgeServiceFactory = ({ }); }; + const findSecretIdsByFolderIdAndKeys = async ({ folderId, keys }: { folderId: string; keys: string[] }) => { + const secrets = await secretDAL.find({ folderId, $in: { [`${TableName.SecretV2}.key` as "key"]: keys } }); + return secrets.map((el) => ({ id: el.id, key: el.key })); + }; + return { createSecret, deleteSecret, @@ -2933,6 +2950,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsByFolderMappings, getSecretById, getAccessibleSecrets, - getSecretVersionsByIds + getSecretVersionsByIds, + findSecretIdsByFolderIdAndKeys }; }; 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 e12dc2ee3..25875cc4e 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -20,11 +20,9 @@ import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; -import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, @@ -36,6 +34,7 @@ import { KmsDataKey } from "../kms/kms-types"; import { getBotKeyFnFactory } from "../project-bot/project-bot-fns"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TReminderServiceFactory } from "../reminder/reminder-service"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretDALFactory } from "./secret-dal"; @@ -1184,14 +1183,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); @@ -1206,23 +1205,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); } } }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index d00ffa2f3..ed2fed801 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -20,7 +20,6 @@ import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/ import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { getTimeDifferenceInSeconds, groupBy, isSamePath, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -41,7 +40,6 @@ import { TIntegrationAuthServiceFactory } from "../integration-auth/integration- import { syncIntegrationSecrets } from "../integration-auth/integration-sync-secret"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; -import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectDALFactory } from "../project/project-dal"; import { createProjectKey } from "../project/project-fns"; @@ -50,12 +48,12 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TReminderServiceFactory } from "../reminder/reminder-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; -import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; @@ -93,7 +91,6 @@ type TSecretQueueFactoryDep = { projectKeyDAL: Pick; projectMembershipDAL: Pick; smtpService: TSmtpService; - orgDAL: Pick; secretVersionDAL: TSecretVersionDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; secretTagDAL: TSecretTagDALFactory; @@ -113,11 +110,11 @@ type TSecretQueueFactoryDep = { projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; folderCommitService: Pick; - secretReminderRecipientsDAL: Pick< - TSecretReminderRecipientsDALFactory, - "delete" | "findUsersBySecretId" | "insertMany" | "transaction" - >; secretSyncQueue: Pick; + reminderService: Pick< + TReminderServiceFactory, + "createReminderInternal" | "deleteReminderBySecretId" | "removeReminderRecipients" + >; }; export type TGetSecrets = { @@ -155,7 +152,6 @@ export const secretQueueFactory = ({ userDAL, webhookDAL, projectEnvDAL, - orgDAL, smtpService, projectDAL, projectBotDAL, @@ -178,9 +174,9 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, - secretReminderRecipientsDAL, secretSyncQueue, - folderCommitService + folderCommitService, + reminderService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -190,19 +186,8 @@ export const secretQueueFactory = ({ const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => { if (deleteRecipients) { - await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx); + await reminderService.deleteReminderBySecretId(dto.secretId, 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 => { @@ -238,15 +223,8 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ - oldSecret, - newSecret, - projectId, - deleteRecipients = true - }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ oldSecret, newSecret, secretReminderRecipients }: TCreateSecretReminderDTO) => { try { - const appCfg = getConfig(); - if (oldSecret.id !== newSecret.id) { throw new BadRequestError({ name: "SecretReminderIdMismatch", @@ -261,38 +239,12 @@ 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 + }); } catch (err) { logger.error(err, "Failed to create secret reminder."); throw new BadRequestError({ @@ -302,58 +254,31 @@ export const secretQueueFactory = ({ } }; - const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { + const handleSecretReminder = async ({ newSecret, oldSecret }: 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, + secretReminderRecipients: secretReminderRecipients ?? [], + deleteRecipients: false + }); + } else if ( + secretReminderRepeatDays === null && + secretReminderNote === null && + oldSecret.secretReminderRepeatDays + ) { + await removeSecretReminder({ + secretId: oldSecret.id, + repeatDays: oldSecret.secretReminderRepeatDays + }); } - - if (recipientsUpdated) { - // if no recipients, delete all existing recipients - if (!secretReminderRecipients?.length) { - const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx); - if (existingRecipients) { - await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); - } - } else { - await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); - await secretReminderRecipientsDAL.insertMany( - secretReminderRecipients.map((r) => ({ - secretId: newSecret.id, - userId: r, - projectId - })), - tx - ); - } - } - }); + } }; const createManySecretsRawFn = createManySecretsRawFnFactory({ projectDAL, @@ -1111,62 +1036,9 @@ export const secretQueueFactory = ({ } }); + // TODO: 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 ce03791f2..bff865722 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -48,6 +48,7 @@ import { ChangeType } from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TReminderServiceFactory } from "../reminder/reminder-service"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; @@ -131,6 +132,7 @@ type TSecretServiceFactoryDep = { "insertMany" | "insertApprovalSecretTags" >; licenseService: Pick; + reminderService: Pick; }; export type TSecretServiceFactory = ReturnType; @@ -153,7 +155,8 @@ export const secretServiceFactory = ({ secretApprovalRequestSecretDAL, secretV2BridgeService, secretApprovalRequestService, - licenseService + licenseService, + reminderService }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist @@ -390,8 +393,7 @@ export const secretServiceFactory = ({ id: secrets[0].id, ...inputSecret }, - oldSecret: secrets[0], - projectId + oldSecret: secrets[0] }); const tags = inputSecret.tags ? await secretTagDAL.findManyTagsById(projectId, inputSecret.tags) : []; @@ -1660,8 +1662,6 @@ export const secretServiceFactory = ({ secretComment, secretValue, tagIds, - reminderNote: secretReminderNote, - reminderRepeatDays: secretReminderRepeatDays, secretMetadata } ] @@ -1824,9 +1824,6 @@ export const secretServiceFactory = ({ secretComment, secretValue, tagIds, - reminderNote: secretReminderNote, - reminderRepeatDays: secretReminderRepeatDays, - secretReminderRecipients, secretMetadata } ] @@ -1835,9 +1832,6 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Approval as const, approval }; } const secret = await secretV2BridgeService.updateSecret({ - secretReminderRepeatDays, - secretReminderNote, - secretReminderRecipients, skipMultilineEncoding, tagIds, secretComment, @@ -1855,6 +1849,22 @@ export const secretServiceFactory = ({ secretValue, secretMetadata }); + + if (secretReminderRepeatDays) { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + reminder: { + secretId: secret.id, + message: secretReminderNote, + repeatDays: secretReminderRepeatDays, + recipients: secretReminderRecipients + } + }); + } return { type: SecretProtectionType.Direct as const, secret }; } @@ -2246,6 +2256,30 @@ export const secretServiceFactory = ({ secrets: inputSecrets, mode }); + + await Promise.all( + inputSecrets + .filter((el) => el.secretReminderRepeatDays) + .map(async (secret) => { + await reminderService.createReminder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + reminder: { + secretId: secrets.find( + (el) => + (el.secretKey === secret.secretKey || el.secretKey === secret.newSecretName) && + el.secretPath === (secret.secretPath || secretPath) + )?.id, + message: secret.secretReminderNote, + repeatDays: secret.secretReminderRepeatDays + } + }); + }) + ); + return { type: SecretProtectionType.Direct as const, secrets }; } diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 12b8e7175..0d0f9b76c 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -310,6 +310,7 @@ export type TUpdateManySecretRawDTO = Omit & { secretMetadata?: ResourceMetadataDTO; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretPath?: string; }[]; }; @@ -403,13 +404,12 @@ export type TFnSecretBlindIndexCheckV2 = { export type THandleReminderDTO = { newSecret: TPartialInputSecret; oldSecret: TPartialSecret; - projectId: string; }; export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; - projectId: string; + secretReminderRecipients: string[]; deleteRecipients?: boolean; }; diff --git a/frontend/src/components/v2/DatePicker/DatePicker.tsx b/frontend/src/components/v2/DatePicker/DatePicker.tsx index 88520f31a..028d6623a 100644 --- a/frontend/src/components/v2/DatePicker/DatePicker.tsx +++ b/frontend/src/components/v2/DatePicker/DatePicker.tsx @@ -18,6 +18,7 @@ export type DatePickerProps = Omit & { popUpProps: PopoverProps; popUpContentProps: PopoverContentProps; dateFormat?: "PPP" | "PP" | "P"; // extend as needed + hideTime?: boolean; }; // Doc: https://react-day-picker.js.org/ @@ -27,6 +28,7 @@ export const DatePicker = ({ popUpProps, popUpContentProps, dateFormat = "PPP", + hideTime = false, ...props }: DatePickerProps) => { const [timeValue, setTimeValue] = useState(value ? format(value, "HH:mm") : "00:00"); @@ -85,14 +87,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..ece19aa55 --- /dev/null +++ b/frontend/src/hooks/api/reminders/index.tsx @@ -0,0 +1,3 @@ +export { useDeleteReminder } from "./queries"; +export { useCreateReminder } from "./queries"; +export { 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..fe8e4a2ef --- /dev/null +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { CreateReminderDTO, DeleteReminderDTO, Reminder } from "./types"; + +export const reminderKeys = { + getReminder: (secretId: string, projectId: string) => + ["get-reminder", secretId, projectId] as const +}; + +export const useCreateReminder = (secretId: string, projectId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { + const { data } = await apiRequest.post<{ reminder: Reminder }>( + `/api/v1/reminders/${projectId}/reminder`, + { + secretId, + message, + repeatDays, + nextReminderDate, + recipients + } + ); + return data.reminder; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) }); + } + }); +}; + +export const useDeleteReminder = (secretId: string, projectId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.delete<{ reminder: Reminder }>( + `/api/v1/reminders/${projectId}/reminder/${secretId}` + ); + return data.reminder; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) }); + } + }); +}; + +export const useGetReminder = (secretId: string, projectId: string) => { + return useQuery({ + queryKey: reminderKeys.getReminder(secretId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ reminder: Reminder }>( + `/api/v1/reminders/${projectId}/reminder/${secretId}` + ); + return data.reminder; + }, + enabled: Boolean(secretId && projectId) + }); +}; diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts new file mode 100644 index 000000000..2fe31aa14 --- /dev/null +++ b/frontend/src/hooks/api/reminders/types.ts @@ -0,0 +1,21 @@ +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; + message?: string | null; + repeatDays?: number | null; + nextReminderDate?: Date | null; + secretId: string; + recipients?: string[]; +}; 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..fe020572b 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,67 @@ -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; + secret: { + id: 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 +70,403 @@ 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, + secret, + 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(secret?.id, workspaceId); + const { mutateAsync: deleteReminder } = useDeleteReminder(secret?.id, workspaceId); + + // 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(secret.id, workspaceId) + }); }; - useEffect(() => { - // On initial load, filter the members to only include the recipients - if (members.length) { - const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id)); - setValue( - "recipients", - filteredMembers.map((m) => ({ - label: m.user.username || m.user.email, - value: m.user.id - })) - ); - } - }, [members, isOpen, recipients]); + // Form submission handler + const handleFormSubmit = async (data: TReminderFormSchema) => { + try { + await createReminder({ + repeatDays: data.repeatDays, + message: data.message, + recipients: data.recipients?.map((r) => r.value) || [], + secretId: secret.id, + 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: secret.id }); + 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"} -
- - )} - /> -
- - -