From 60a4c72a5ddb36b5fa1c71aaa5fcdb0e50f0acf1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 30 Jun 2025 20:10:30 -0400 Subject: [PATCH] feat: Re-invite users every 1 week for up to a month. --- .../20250630212553_user-latest-invite.ts | 21 ++++++++ backend/src/db/schemas/org-memberships.ts | 3 +- backend/src/server/routes/index.ts | 3 +- .../org-membership/org-membership-dal.ts | 31 +++++++++++- backend/src/services/org/org-service.ts | 48 ++++++++++++++++++- .../resource-cleanup-queue.ts | 6 ++- .../MinimizedOrgSidebar.tsx | 5 +- 7 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 backend/src/db/migrations/20250630212553_user-latest-invite.ts diff --git a/backend/src/db/migrations/20250630212553_user-latest-invite.ts b/backend/src/db/migrations/20250630212553_user-latest-invite.ts new file mode 100644 index 000000000..6d8c01248 --- /dev/null +++ b/backend/src/db/migrations/20250630212553_user-latest-invite.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (!hasColumn) { + t.datetime("lastInvitedAt").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.OrgMembership, "lastInvitedAt"); + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (hasColumn) { + t.dropColumn("lastInvitedAt"); + } + }); +} diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index e77b6e9c9..939033c71 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -18,7 +18,8 @@ export const OrgMembershipsSchema = z.object({ orgId: z.string().uuid(), roleId: z.string().uuid().nullable().optional(), projectFavorites: z.string().array().nullable().optional(), - isActive: z.boolean().default(true) + isActive: z.boolean().default(true), + lastInvitedAt: z.date().nullable().optional() }); export type TOrgMemberships = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index aeef89838..1b6cad5f0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1615,7 +1615,8 @@ export const registerRoutes = async ( secretSharingDAL, secretVersionV2DAL: secretVersionV2BridgeDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, - serviceTokenService + serviceTokenService, + orgService }); const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({ diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 68b117202..0cfc98f10 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -103,8 +103,37 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findRecentInvitedMemberships = async () => { + try { + const now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const threeMonthsAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + + const memberships = await db + .replicaNode()(TableName.OrgMembership) + .where("status", "invited") + .where((qb) => { + // lastInvitedAt is null AND createdAt is between 1 week and 3 months ago + void qb.whereNull("lastInvitedAt").whereBetween("createdAt", [threeMonthsAgo, oneWeekAgo]); + }) + .orWhere((qb) => { + // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago + void qb.where("lastInvitedAt", "<", oneWeekAgo).where("createdAt", ">", oneMonthAgo); + }); + + return memberships; + } catch (error) { + throw new DatabaseError({ + error, + name: "Find recent invited memberships" + }); + } + }; + return { ...orgMembershipOrm, - findOrgMembershipById + findOrgMembershipById, + findRecentInvitedMemberships }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1215b7c00..c509ec2db 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -108,7 +108,10 @@ type TOrgServiceFactoryDep = { "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" >; projectKeyDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "findOrgMembershipById" | "findOne" | "findById" | "findRecentInvitedMemberships" | "updateById" + >; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; oidcConfigDAL: Pick; @@ -1424,6 +1427,46 @@ export const orgServiceFactory = ({ return incidentContact; }; + /** + * Re-send emails to users who haven't accepted an invite yet + */ + const notifyInvitedUsers = async () => { + const invitedUsers = await orgMembershipDAL.findRecentInvitedMemberships(); + const appCfg = getConfig(); + + await Promise.all( + invitedUsers.map(async (invitedUser) => { + const org = await orgDAL.findById(invitedUser.orgId); + if (!org) return; + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: invitedUser.id, + orgId: org.id + }); + + if (invitedUser.inviteEmail) { + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: `Reminder: You have been invited to ${org.name} on Infisical`, + recipients: [invitedUser.inviteEmail], + substitutions: { + organizationName: org.name, + email: invitedUser.inviteEmail, + organizationId: org.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + } + + await orgMembershipDAL.updateById(invitedUser.id, { + lastInvitedAt: new Date() + }); + }) + ); + }; + return { findOrganizationById, findAllOrgMembers, @@ -1447,6 +1490,7 @@ export const orgServiceFactory = ({ listProjectMembershipsByOrgMembershipId, findOrgBySlug, resendOrgMemberInvitation, - upgradePrivilegeSystem + upgradePrivilegeSystem, + notifyInvitedUsers }; }; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 32f180636..deb4d0cb2 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -5,6 +5,7 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; +import { TOrgServiceFactory } from "../org/org-service"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; @@ -24,6 +25,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { secretSharingDAL: Pick; serviceTokenService: Pick; queueService: TQueueServiceFactory; + orgService: TOrgServiceFactory; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -39,7 +41,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretSharingDAL, secretVersionV2DAL, identityUniversalAuthClientSecretDAL, - serviceTokenService + serviceTokenService, + orgService }: TDailyResourceCleanUpQueueServiceFactoryDep) => { queueService.start(QueueName.DailyResourceCleanUp, async () => { logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); @@ -54,6 +57,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); await serviceTokenService.notifyExpiringTokens(); + await orgService.notifyInvitedUsers(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index a005c1359..f4ce9bc40 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -164,7 +164,10 @@ export const MinimizedOrgSidebar = () => { const handleCopyToken = async () => { try { await window.navigator.clipboard.writeText(getAuthToken()); - createNotification({ type: "success", text: "Copied current login session token to clipboard" }); + createNotification({ + type: "success", + text: "Copied current login session token to clipboard" + }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to copy user token to clipboard" });