diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index d140e0d82..7fac20c0e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -1217,8 +1217,8 @@ declare module "knex/types/tables" { TSecretScanningConfigsInsert, TSecretScanningConfigsUpdate >; - [TableName.Reminders]: KnexOriginal.CompositeTableType; - [TableName.RemindersRecipients]: KnexOriginal.CompositeTableType< + [TableName.Reminder]: KnexOriginal.CompositeTableType; + [TableName.ReminderRecipient]: KnexOriginal.CompositeTableType< TRemindersRecipients, TRemindersRecipientsInsert, TRemindersRecipientsUpdate diff --git a/backend/src/db/migrations/20250701202824_add-reminder-table.ts b/backend/src/db/migrations/20250701202824_add-reminder-table.ts index c554545f1..73bb083e4 100644 --- a/backend/src/db/migrations/20250701202824_add-reminder-table.ts +++ b/backend/src/db/migrations/20250701202824_add-reminder-table.ts @@ -4,39 +4,41 @@ 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) => { + if (!(await knex.schema.hasTable(TableName.Reminder))) { + await knex.schema.createTable(TableName.Reminder, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("secretId").nullable(); t.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE"); - t.string("message").nullable(); - t.integer("repeatDays").nullable(); + t.string("message", 1024).nullable(); + t.integer("repeatDays").checkPositive().nullable(); t.timestamp("nextReminderDate").notNullable(); t.timestamps(true, true, true); t.index("secretId"); + t.unique("secretId"); }); } - if (!(await knex.schema.hasTable(TableName.RemindersRecipients))) { - await knex.schema.createTable(TableName.RemindersRecipients, (t) => { + if (!(await knex.schema.hasTable(TableName.ReminderRecipient))) { + await knex.schema.createTable(TableName.ReminderRecipient, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("reminderId").notNullable(); - t.foreign("reminderId").references("id").inTable(TableName.Reminders).onDelete("CASCADE"); + t.foreign("reminderId").references("id").inTable(TableName.Reminder).onDelete("CASCADE"); t.uuid("userId").notNullable(); t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); t.timestamps(true, true, true); t.index("reminderId"); t.index("userId"); + t.unique(["reminderId", "userId"]); }); } - await createOnUpdateTrigger(knex, TableName.Reminders); - await createOnUpdateTrigger(knex, TableName.RemindersRecipients); + await createOnUpdateTrigger(knex, TableName.Reminder); + await createOnUpdateTrigger(knex, TableName.ReminderRecipient); } 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); + await dropOnUpdateTrigger(knex, TableName.Reminder); + await dropOnUpdateTrigger(knex, TableName.ReminderRecipient); + await knex.schema.dropTableIfExists(TableName.Reminder); + await knex.schema.dropTableIfExists(TableName.ReminderRecipient); } diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index cf829c78a..f200f499e 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -175,8 +175,8 @@ export enum TableName { SecretScanningConfig = "secret_scanning_configs", // reminders - Reminders = "reminders", - RemindersRecipients = "reminders_recipients" + Reminder = "reminders", + ReminderRecipient = "reminders_recipients" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a2acb62c4..661c7ee49 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -458,7 +458,11 @@ export enum EventType { CREATE_PROJECT = "create-project", UPDATE_PROJECT = "update-project", - DELETE_PROJECT = "delete-project" + DELETE_PROJECT = "delete-project", + + CREATE_SECRET_REMINDER = "create-secret-reminder", + GET_SECRET_REMINDER = "get-secret-reminder", + DELETE_SECRET_REMINDER = "delete-secret-reminder" } export const filterableSecretEvents: EventType[] = [ @@ -3292,6 +3296,31 @@ interface SecretScanningConfigUpdateEvent { }; } +interface SecretReminderCreateEvent { + type: EventType.CREATE_SECRET_REMINDER; + metadata: { + secretId: string; + message?: string | null; + repeatDays?: number | null; + nextReminderDate?: string | null; + recipients?: string[] | null; + }; +} + +interface SecretReminderGetEvent { + type: EventType.GET_SECRET_REMINDER; + metadata: { + secretId: string; + }; +} + +interface SecretReminderDeleteEvent { + type: EventType.DELETE_SECRET_REMINDER; + metadata: { + secretId: string; + }; +} + interface SecretScanningConfigReadEvent { type: EventType.SECRET_SCANNING_CONFIG_GET; metadata?: Record; // not needed, based off projectId @@ -3654,4 +3683,7 @@ export type Event = | OrgUpdateEvent | ProjectCreateEvent | ProjectUpdateEvent - | ProjectDeleteEvent; + | ProjectDeleteEvent + | SecretReminderCreateEvent + | SecretReminderGetEvent + | SecretReminderDeleteEvent; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 708efa8c8..11eaeea8e 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -405,7 +405,7 @@ export type TQueueServiceFactory = { name: QueueName, startOffset?: number, endOffset?: number - ) => Promise<{ delay: number; timestamp: number; repeatJobKey?: string }[]>; + ) => Promise<{ delay: number; timestamp: number; repeatJobKey?: string; data?: unknown }[]>; }; export const queueServiceFactory = ( diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 075b6a853..62795d4f6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -248,7 +248,7 @@ import { projectRoleServiceFactory } from "@app/services/project-role/project-ro 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 { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal"; import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; import { secretDALFactory } from "@app/services/secret/secret-dal"; @@ -743,7 +743,8 @@ export const registerRoutes = async ( reminderRecipientDAL, smtpService, projectMembershipDAL, - permissionService + permissionService, + secretV2BridgeDAL }); const orgService = orgServiceFactory({ diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 2981763c5..99b9de14e 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -85,6 +85,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); + await server.register(registerReminderRouter, { prefix: "/reminders" }); await server.register(registerSecretFolderRouter, { prefix: "/folders" }); await server.register( @@ -173,6 +174,4 @@ 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 index 96396172a..e3ce9fdf6 100644 --- a/backend/src/server/routes/v1/reminder-router.ts +++ b/backend/src/server/routes/v1/reminder-router.ts @@ -1,28 +1,33 @@ import { z } from "zod"; import { RemindersSchema } from "@app/db/schemas/reminders"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerReminderRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:projectId/reminder", + url: "/:projectId/reminder/:secretId", 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() + projectId: z.string().uuid(), + secretId: z.string().uuid() }), + body: z + .object({ + message: z.string().trim().max(1024).optional(), + repeatDays: z.number().min(1).nullable().optional(), + nextReminderDate: z.string().datetime().nullable().optional(), + recipients: z.string().array().optional() + }) + .refine((data) => { + return data.repeatDays || data.nextReminderDate; + }, "At least one of repeatDays or nextReminderDate is required"), response: { 200: z.object({ message: z.string() @@ -37,8 +42,31 @@ export const registerReminderRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, projectId: req.params.projectId, - reminder: req.body + reminder: { + secretId: req.params.secretId, + message: req.body.message, + repeatDays: req.body.repeatDays, + nextReminderDate: req.body.nextReminderDate, + recipients: req.body.recipients + } }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.CREATE_SECRET_REMINDER, + metadata: { + secretId: req.params.secretId, + message: req.body.message, + repeatDays: req.body.repeatDays, + nextReminderDate: req.body.nextReminderDate, + recipients: req.body.recipients + } + } + }); + return { message: "Successfully created reminder" }; } }); @@ -74,6 +102,18 @@ export const registerReminderRouter = async (server: FastifyZodProvider) => { secretId: req.params.secretId, projectId: req.params.projectId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.GET_SECRET_REMINDER, + metadata: { + secretId: req.params.secretId + } + } + }); return { reminder }; } }); @@ -105,6 +145,18 @@ export const registerReminderRouter = async (server: FastifyZodProvider) => { secretId: req.params.secretId, projectId: req.params.projectId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.DELETE_SECRET_REMINDER, + metadata: { + secretId: req.params.secretId + } + } + }); return { message: "Successfully deleted reminder" }; } }); diff --git a/backend/src/services/reminder-recipients/reminder-dal.ts b/backend/src/services/reminder-recipients/reminder-recipient-dal.ts similarity index 81% rename from backend/src/services/reminder-recipients/reminder-dal.ts rename to backend/src/services/reminder-recipients/reminder-recipient-dal.ts index 251ddd98e..67988d4db 100644 --- a/backend/src/services/reminder-recipients/reminder-dal.ts +++ b/backend/src/services/reminder-recipients/reminder-recipient-dal.ts @@ -5,7 +5,7 @@ import { ormify } from "@app/lib/knex"; export type TReminderRecipientDALFactory = ReturnType; export const reminderRecipientDALFactory = (db: TDbClient) => { - const reminderRecipientOrm = ormify(db, TableName.RemindersRecipients); + const reminderRecipientOrm = ormify(db, TableName.ReminderRecipient); return { ...reminderRecipientOrm }; }; diff --git a/backend/src/services/reminder-recipients/reminder-types.ts b/backend/src/services/reminder-recipients/reminder-types.ts deleted file mode 100644 index 194b19f9c..000000000 --- a/backend/src/services/reminder-recipients/reminder-types.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 index 14d4d8cf8..97064c064 100644 --- a/backend/src/services/reminder/reminder-dal.ts +++ b/backend/src/services/reminder/reminder-dal.ts @@ -16,7 +16,7 @@ import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex" export type TReminderDALFactory = ReturnType; export const reminderDALFactory = (db: TDbClient) => { - const reminderOrm = ormify(db, TableName.Reminders); + const reminderOrm = ormify(db, TableName.Reminder); const getTodayDateRange = () => { const now = new Date(); @@ -29,15 +29,11 @@ export const reminderDALFactory = (db: TDbClient) => { const findSecretDailyReminders = async (tx?: Knex) => { const { startOfDay, endOfDay } = getTodayDateRange(); - const rawReminders = await (tx || db)(TableName.Reminders) + const rawReminders = await (tx || db)(TableName.Reminder) .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.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) + .leftJoin(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.SecretV2, `${TableName.Reminder}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( TableName.SecretFolder, `${TableName.SecretV2}.folderId`, @@ -50,7 +46,7 @@ export const reminderDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) .leftJoin(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - .select(selectAllTableCols(TableName.Reminders)) + .select(selectAllTableCols(TableName.Reminder)) .select(db.ref("email").withSchema(TableName.Users)) .select(db.ref("name").withSchema(TableName.Project).as("projectName")) .select(db.ref("id").withSchema(TableName.Project).as("projectId")) @@ -84,30 +80,22 @@ export const reminderDALFactory = (db: TDbClient) => { const futureDate = new Date(startOfDay); futureDate.setDate(futureDate.getDate() + daysAhead); - const reminders = await (tx || db)(TableName.Reminders) + const reminders = await (tx || db)(TableName.Reminder) .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)); + .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) + .select(selectAllTableCols(TableName.Reminder)) + .select(db.ref("userId").withSchema(TableName.ReminderRecipient)); return reminders; }; const findSecretReminder = async (secretId: string, tx?: Knex) => { - const rawReminders = await (tx || db)(TableName.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 rawReminders = await (tx || db)(TableName.Reminder) + .where(`${TableName.Reminder}.secretId`, secretId) + .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) + .select(selectAllTableCols(TableName.Reminder)) + .select(db.ref("userId").withSchema(TableName.ReminderRecipient)); const reminders = sqlNestRelationships({ data: rawReminders, key: "id", diff --git a/backend/src/services/reminder/reminder-queue.ts b/backend/src/services/reminder/reminder-queue.ts index 2439c34d8..233c8c7a8 100644 --- a/backend/src/services/reminder/reminder-queue.ts +++ b/backend/src/services/reminder/reminder-queue.ts @@ -11,7 +11,7 @@ import { TReminderServiceFactory } from "./reminder-service"; type TDailyReminderQueueServiceFactoryDep = { reminderService: TReminderServiceFactory; queueService: TQueueServiceFactory; - secretDAL: Pick; + secretDAL: Pick; secretReminderRecipientsDAL: Pick; }; @@ -51,11 +51,12 @@ export const dailyReminderQueueServiceFactory = ({ if (match) { map.set(match[0], { timestamp: job.timestamp, - delay: job.delay + delay: job.delay, + data: job.data }); } return map; - }, new Map()); + }, new Map()); if (reminderJobs.length === 0) { logger.info(`${QueueName.SecretReminderMigration}: no reminder jobs found`); return; @@ -96,21 +97,26 @@ export const dailyReminderQueueServiceFactory = ({ } } - 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 secretDAL.transaction(async (tx) => { + await reminderService.batchCreateReminders( + secretsWithReminder.map((secret) => { + const delayedJob = reminderDelayedJobs.get(secret.id); + const projectId = (delayedJob?.data as { projectId?: string })?.projectId; + const nextDate = delayedJob ? new Date(delayedJob.timestamp + delayedJob.delay) : undefined; + return { + secretId: secret.id, + message: secret.reminderNote, + repeatDays: secret.reminderRepeatDays, + nextReminderDate: nextDate, + recipients: secret.recipients || [], + projectId + }; + }), + tx + ); - await secretReminderRecipientsDAL.delete({ $in: { secretId: secretsWithReminder.map((s) => s.id) } }); + await secretReminderRecipientsDAL.delete({ $in: { secretId: secretsWithReminder.map((s) => s.id) } }, tx); + }); numberOfRetryOnFailure = 0; } catch (error) { @@ -176,11 +182,11 @@ export const dailyReminderQueueServiceFactory = ({ }; queueService.listen(QueueName.DailyReminders, "failed", (_, err) => { - logger.error(err, `${QueueName.DailyReminders}: resource cleanup failed`); + logger.error(err, `${QueueName.DailyReminders}: daily reminder processing failed`); }); queueService.listen(QueueName.SecretReminderMigration, "failed", (_, err) => { - logger.error(err, `${QueueName.SecretReminderMigration}: resource cleanup failed`); + logger.error(err, `${QueueName.SecretReminderMigration}: secret reminder migration failed`); }); return { diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts index b9e497d96..078bde726 100644 --- a/backend/src/services/reminder/reminder-service.ts +++ b/backend/src/services/reminder/reminder-service.ts @@ -9,7 +9,8 @@ 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 { TReminderRecipientDALFactory } from "../reminder-recipients/reminder-recipient-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TReminderDALFactory } from "./reminder-dal"; import { TBatchCreateReminderDTO, TCreateReminderDTO } from "./reminder-types"; @@ -20,6 +21,7 @@ type TReminderServiceFactoryDep = { smtpService: TSmtpService; projectMembershipDAL: Pick; permissionService: Pick; + secretV2BridgeDAL: Pick; }; export type TReminderServiceFactory = ReturnType; @@ -29,7 +31,8 @@ export const reminderServiceFactory = ({ reminderRecipientDAL, smtpService, projectMembershipDAL, - permissionService + permissionService, + secretV2BridgeDAL }: TReminderServiceFactoryDep) => { const addDays = (days: number, fromDate: Date = new Date()): Date => { const result = new Date(fromDate); @@ -76,13 +79,15 @@ export const reminderServiceFactory = ({ message, repeatDays, nextReminderDate: nextReminderDateInput, - recipients + recipients, + projectId }: { secretId?: string; message?: string | null; repeatDays?: number | null; nextReminderDate?: string | null; recipients?: string[] | null; + projectId: string; }) => { if (!secretId) { throw new BadRequestError({ message: "secretId is required" }); @@ -124,7 +129,7 @@ export const reminderServiceFactory = ({ // Manage recipients (add/update/delete as needed) await $manageReminderRecipients(reminderId, recipients); - + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); return { id: reminderId, created: !existingReminder }; }; @@ -145,7 +150,10 @@ export const reminderServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); - const response = await createReminderInternal(reminder); + const response = await createReminderInternal({ + ...reminder, + projectId + }); return response; }; @@ -184,28 +192,30 @@ export const reminderServiceFactory = ({ 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 || "" + await reminderDAL.transaction(async (tx) => { + const recipients: string[] = reminder.recipients + .map((r) => r.email) + .filter((email): email is string => Boolean(email)); + if (recipients.length === 0) { + const members = await projectMembershipDAL.findAllProjectMembers(reminder.projectId); + recipients.push(...members.map((m) => m.user.email).filter((email): email is string => Boolean(email))); + } + await smtpService.sendMail({ + template: SmtpTemplates.SecretReminder, + subjectLine: "Infisical secret reminder", + recipients, + substitutions: { + reminderNote: reminder.message || "", + projectName: reminder.projectName || "", + organizationName: reminder.organizationName || "" + } + }); + if (reminder.repeatDays) { + await reminderDAL.updateById(reminder.id, { nextReminderDate: addDays(reminder.repeatDays) }, tx); + } else { + await reminderDAL.deleteById(reminder.id, tx); } }); - if (reminder.repeatDays) { - await reminderDAL.updateById(reminder.id, { nextReminderDate: addDays(reminder.repeatDays) }); - } else { - await reminderDAL.deleteById(reminder.id); - } } catch (error) { logger.error( error, @@ -240,27 +250,30 @@ export const reminderServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets); await reminderDAL.delete({ secretId }); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); }; - const removeReminderRecipients = async (secretId: string, tx?: Knex) => { + const removeReminderRecipients = async (secretId: string, projectId: string, tx?: Knex) => { const reminder = await reminderDAL.findOne({ secretId }, tx); if (!reminder) { return; } await reminderRecipientDAL.delete({ reminderId: reminder.id }, tx); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); }; - const deleteReminderBySecretId = async (secretId: string, tx?: Knex) => { + const deleteReminderBySecretId = async (secretId: string, projectId: string, tx?: Knex) => { await reminderDAL.delete({ secretId }, tx); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); }; - const batchCreateReminders = async (remindersData: TBatchCreateReminderDTO) => { + const batchCreateReminders = async (remindersData: TBatchCreateReminderDTO, tx?: Knex) => { if (!remindersData || remindersData.length === 0) { return { created: 0, reminderIds: [] }; } const processedReminders = remindersData.map( - ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients }) => { + ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => { let nextReminderDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); @@ -281,18 +294,21 @@ export const reminderServiceFactory = ({ message, repeatDays, nextReminderDate, - recipients: recipients ? [...new Set(recipients)] : [] + recipients: recipients ? [...new Set(recipients)] : [], + projectId }; } ); const newReminders = await reminderDAL.insertMany( - processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ + processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, projectId }) => ({ secretId, message, repeatDays, - nextReminderDate - })) + nextReminderDate, + projectId + })), + tx ); const allRecipientInserts: Array<{ reminderId: string; userId: string }> = []; @@ -310,7 +326,12 @@ export const reminderServiceFactory = ({ }); if (allRecipientInserts.length > 0) { - await reminderRecipientDAL.insertMany(allRecipientInserts); + await reminderRecipientDAL.insertMany(allRecipientInserts, tx); + } + + const projectIds = new Set(processedReminders.map((r) => r.projectId).filter((id): id is string => Boolean(id))); + for (const projectId of projectIds) { + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); } return { diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts index ea6bd1d63..ca0d518b8 100644 --- a/backend/src/services/reminder/reminder-types.ts +++ b/backend/src/services/reminder/reminder-types.ts @@ -24,21 +24,11 @@ export type TCreateReminderDTO = { }; }; -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; + projectId?: string; }[]; 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 40fd0bbf8..196a86180 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 @@ -519,13 +519,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${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`) + .leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`) + .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) + .leftJoin(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`) .where((qb) => { if (filters?.metadataFilter && filters.metadataFilter.length > 0) { filters.metadataFilter.forEach((meta) => { @@ -548,11 +544,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { }) as rank` ) ) - .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("id").withSchema(TableName.Reminder).as("reminderId")) + .select(db.ref("message").withSchema(TableName.Reminder).as("reminderNote")) + .select(db.ref("repeatDays").withSchema(TableName.Reminder).as("reminderRepeatDays")) + .select(db.ref("nextReminderDate").withSchema(TableName.Reminder).as("nextReminderDate")) + .select(db.ref("id").withSchema(TableName.ReminderRecipient).as("reminderRecipientId")) .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) @@ -825,13 +821,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { // 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` - ) + .leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`) + .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) .select(selectAllTableCols(TableName.SecretV2)) - .select(db.ref("userId").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientUserId")); + .select(db.ref("userId").withSchema(TableName.ReminderRecipient).as("reminderRecipientUserId")); const data = sqlNestRelationships({ data: docs, 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 0d366ea1e..23a69990b 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 @@ -376,7 +376,8 @@ export const fnSecretBulkDelete = async ({ secretDAL, secretQueueService, folderCommitService, - secretVersionDAL + secretVersionDAL, + projectId }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretKey }) => ({ @@ -392,7 +393,10 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) .map(({ id, reminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx) + secretQueueService.removeSecretReminder( + { secretId: id, repeatDays: reminderRepeatDays as number, projectId }, + tx + ) ) ); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index f4171b1a7..4027f784c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -246,6 +246,7 @@ export type TCreateSecretReminderDTO = { export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + projectId: string; }; export type TBackFillSecretReferencesDTO = TProjectPermission; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 25875cc4e..c0116e26e 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -740,7 +740,8 @@ export const fnSecretBulkDelete = async ({ tx, actorId, secretDAL, - secretQueueService + secretQueueService, + projectId }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretBlindIndex }) => ({ @@ -756,7 +757,10 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) .map(({ id, secretReminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx) + secretQueueService.removeSecretReminder( + { secretId: id, repeatDays: secretReminderRepeatDays as number, projectId }, + tx + ) ) ); @@ -1211,7 +1215,7 @@ export const fnDeleteProjectSecretReminders = async ( : (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays; if (repeatDays) { - await reminderService.deleteReminderBySecretId(secret.id); + await reminderService.deleteReminderBySecretId(secret.id, projectId); } } }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index ed2fed801..63c75e125 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -186,7 +186,7 @@ export const secretQueueFactory = ({ const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => { if (deleteRecipients) { - await reminderService.deleteReminderBySecretId(dto.secretId, tx); + await reminderService.deleteReminderBySecretId(dto.secretId, dto.projectId, tx); } }; @@ -223,7 +223,12 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ oldSecret, newSecret, secretReminderRecipients }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ + oldSecret, + newSecret, + projectId, + secretReminderRecipients + }: TCreateSecretReminderDTO) => { try { if (oldSecret.id !== newSecret.id) { throw new BadRequestError({ @@ -243,7 +248,8 @@ export const secretQueueFactory = ({ secretId: newSecret.id, message: newSecret.secretReminderNote, repeatDays: newSecret.secretReminderRepeatDays, - recipients: secretReminderRecipients + recipients: secretReminderRecipients, + projectId }); } catch (err) { logger.error(err, "Failed to create secret reminder."); @@ -254,7 +260,7 @@ export const secretQueueFactory = ({ } }; - const handleSecretReminder = async ({ newSecret, oldSecret }: THandleReminderDTO) => { + const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret; if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) { @@ -265,6 +271,7 @@ export const secretQueueFactory = ({ await addSecretReminder({ oldSecret, newSecret, + projectId, secretReminderRecipients: secretReminderRecipients ?? [], deleteRecipients: false }); @@ -275,7 +282,8 @@ export const secretQueueFactory = ({ ) { await removeSecretReminder({ secretId: oldSecret.id, - repeatDays: oldSecret.secretReminderRepeatDays + repeatDays: oldSecret.secretReminderRepeatDays, + projectId }); } } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index bff865722..5c3a5c2a2 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -393,7 +393,8 @@ export const secretServiceFactory = ({ id: secrets[0].id, ...inputSecret }, - oldSecret: secrets[0] + oldSecret: secrets[0], + projectId }); const tags = inputSecret.tags ? await secretTagDAL.findManyTagsById(projectId, inputSecret.tags) : []; @@ -548,7 +549,8 @@ export const secretServiceFactory = ({ await secretQueueService.removeSecretReminder( { repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id + secretId: secret.id, + projectId }, tx ); @@ -1074,7 +1076,8 @@ export const secretServiceFactory = ({ await secretQueueService.removeSecretReminder( { repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id + secretId: secret.id, + projectId }, tx ); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 0d0f9b76c..8af15818e 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -404,11 +404,13 @@ 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; @@ -417,6 +419,7 @@ export type TCreateSecretReminderDTO = { export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + projectId: string; deleteRecipients?: boolean; }; diff --git a/frontend/src/hooks/api/reminders/index.tsx b/frontend/src/hooks/api/reminders/index.tsx index ece19aa55..b5f16bd3f 100644 --- a/frontend/src/hooks/api/reminders/index.tsx +++ b/frontend/src/hooks/api/reminders/index.tsx @@ -1,3 +1 @@ -export { useDeleteReminder } from "./queries"; -export { useCreateReminder } from "./queries"; -export { useGetReminder } from "./queries"; +export { useCreateReminder, useDeleteReminder, useGetReminder } from "./queries"; diff --git a/frontend/src/hooks/api/reminders/queries.tsx b/frontend/src/hooks/api/reminders/queries.tsx index fe8e4a2ef..e9b29807c 100644 --- a/frontend/src/hooks/api/reminders/queries.tsx +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -15,9 +15,8 @@ export const useCreateReminder = (secretId: string, projectId: string) => { return useMutation({ mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { const { data } = await apiRequest.post<{ reminder: Reminder }>( - `/api/v1/reminders/${projectId}/reminder`, + `/api/v1/reminders/${projectId}/reminder/${secretId}`, { - secretId, message, repeatDays, nextReminderDate, diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts index 2fe31aa14..245805aea 100644 --- a/frontend/src/hooks/api/reminders/types.ts +++ b/frontend/src/hooks/api/reminders/types.ts @@ -11,11 +11,4 @@ export type DeleteReminderDTO = { reminderId: string; }; -export type Reminder = { - id: string; - message?: string | null; - repeatDays?: number | null; - nextReminderDate?: Date | null; - secretId: string; - recipients?: string[]; -}; +export type Reminder = { id: string } & CreateReminderDTO; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index fe020572b..0bb8faffc 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -53,9 +53,7 @@ interface ReminderFormProps { workspaceId: string; environment: string; secretPath: string; - secret: { - id: string; - }; + secretId: string; reminder?: Reminder; } @@ -134,7 +132,7 @@ export const CreateReminderForm = ({ workspaceId, environment, secretPath, - secret, + secretId, reminder }: ReminderFormProps) => { const queryClient = useQueryClient(); @@ -145,8 +143,8 @@ export const CreateReminderForm = ({ const { memberOptions } = useWorkspaceMembers(); // API mutations - const { mutateAsync: createReminder } = useCreateReminder(secret?.id, workspaceId); - const { mutateAsync: deleteReminder } = useDeleteReminder(secret?.id, workspaceId); + const { mutateAsync: createReminder } = useCreateReminder(secretId, workspaceId); + const { mutateAsync: deleteReminder } = useDeleteReminder(secretId, workspaceId); // Form setup const form = useForm({ @@ -185,7 +183,7 @@ export const CreateReminderForm = ({ queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) }); queryClient.invalidateQueries({ - queryKey: reminderKeys.getReminder(secret.id, workspaceId) + queryKey: reminderKeys.getReminder(secretId, workspaceId) }); }; @@ -196,7 +194,7 @@ export const CreateReminderForm = ({ repeatDays: data.repeatDays, message: data.message, recipients: data.recipients?.map((r) => r.value) || [], - secretId: secret.id, + secretId, nextReminderDate: data.nextReminderDate }); @@ -221,7 +219,7 @@ export const CreateReminderForm = ({ // Delete reminder handler const handleDeleteReminder = async () => { try { - await deleteReminder({ reminderId: reminder?.id || "", secretId: secret.id }); + await deleteReminder({ reminderId: reminder?.id || "", secretId }); invalidateQueries(); reset(); onOpenChange(); @@ -387,6 +385,7 @@ export const CreateReminderForm = ({ }} popUpContentProps={{}} hideTime + hidden={{ before: new Date(Date.now() + 86400000) }} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 2feb20e3e..1e6be0529 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -314,7 +314,7 @@ export const SecretDetailSidebar = ({ workspaceId={currentWorkspace.id} environment={environment} secretPath={secretPath} - secret={secret} + secretId={secret?.id} reminder={reminderData} />