Skip to content
Merged
1,290 changes: 575 additions & 715 deletions server/package-lock.json

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@
"@biomejs/biome": "2.2.5",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.10.13",
"@types/node": "^24.7.0",
"@types/pg": "^8.15.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.5",
"pino-pretty": "^13.1.1",
"vite-tsconfig-paths": "^5.1.4",
Expand All @@ -36,8 +35,8 @@
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.938.0",
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/client-secrets-manager": "^3.1013.0",
"@aws-sdk/client-sns": "^3.996.0",
"@aws-sdk/client-secrets-manager": "^3.936.0",
"@aws-sdk/client-ses": "^3.1030.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@trpc/client": "^11.6.0",
"@trpc/server": "^11.6.0",
Expand All @@ -53,7 +52,7 @@
"swagger-ui-express": "^5.0.1",
"trpc-to-openapi": "^3.1.0",
"tsx": "^4.20.6",
"twilio": "^5.12.2",
"twilio": "^5.13.1",
"web-push": "^3.6.7",
"zod": "^4.1.13"
}
Expand Down
31 changes: 31 additions & 0 deletions server/src/routers/invite-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthRepository } from "../data/repository/auth-repo.js";
import { InviteCodeRepository } from "../data/repository/invite-code-repo.js";
import { GLOBAL_CREATE_INVITE_KEY } from "../data/roles.js";
import { InviteCodeService } from "../service/invite-code-service.js";
import { sesService } from "../service/ses-service.js";
import { withErrorHandling } from "../trpc/error_handler.js";
import { procedure, roleProcedure, router } from "../trpc/trpc.js";
import {
Expand All @@ -11,6 +12,8 @@ import {
listInviteCodesInputSchema,
listInviteCodesOutputSchema,
revokeInviteCodeInputSchema,
sendBatchInvitesInputSchema,
sendBatchInvitesOutputSchema,
validateInviteCodeInputSchema,
validateInviteCodeOutputSchema,
} from "../types/invite-code-types.js";
Expand Down Expand Up @@ -117,9 +120,37 @@ const revokeInviteCode = inviteProcedure
});
});

/**
* Send batch invite emails (requires global:create-invite permission)
*/
const sendBatchInvites = inviteProcedure
.input(sendBatchInvitesInputSchema)
.output(sendBatchInvitesOutputSchema)
.meta({
openapi: {
method: "POST",
path: "/inviteCodes.sendBatchInvites",
summary:
"Create invite codes and send email invitations in bulk. Requires global:create-invite permission.",
tags: ["Invite Codes"],
},
})
.mutation(async ({ ctx, input }) => {
return withErrorHandling("sendBatchInvites", async () => {
return await inviteCodeService.sendBatchInvites(
ctx.auth.user.id,
input.emails,
input.roleKeys,
input.expiresInHours,
sesService,
);
});
});

export const inviteCodeRouter = router({
createInviteCode,
validateInviteCode,
listInviteCodes,
revokeInviteCode,
sendBatchInvites,
});
66 changes: 65 additions & 1 deletion server/src/service/invite-code-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import type { InviteCodeRepository } from "../data/repository/invite-code-repo.j
import { hasPermission } from "../data/role-hierarchy.js";
import type { RoleKey } from "../data/roles.js";
import { GLOBAL_CREATE_INVITE_KEY } from "../data/roles.js";
import type { SesService } from "../service/ses-service.js";
import {
ForbiddenError,
NotFoundError,
ValidationError,
} from "../types/errors.js";
import type { InviteCodeStatus } from "../types/invite-code-types.js";
import type {
BatchInviteResult,
InviteCodeStatus,
} from "../types/invite-code-types.js";
import log from "../utils/logger.js";

// Configuration: Default expiration time in hours
Expand Down Expand Up @@ -228,6 +232,66 @@ export class InviteCodeService {
return this.inviteCodeRepo.listInviteCodes(status, limit, offset);
}

/**
* Create invite codes and send emails in batch
* @param adminUserId Admin user sending the invites
* @param emails Array of email addresses to invite
* @param roleKeys Role keys to assign via invite
* @param expiresInHours Hours until codes expire
* @param sesService SES service for sending emails
*/
async sendBatchInvites(
adminUserId: string,
emails: string[],
roleKeys: RoleKey[],
expiresInHours: number | undefined,
sesService: SesService,
): Promise<BatchInviteResult> {
await this.verifyInviteManagementPermission(adminUserId);

const frontendUrl = process.env.FRONTEND_URL ?? "http://localhost:3001";
const results: BatchInviteResult["results"] = [];

for (const email of emails) {
try {
const invite = await this.createInvite(
adminUserId,
roleKeys,
expiresInHours,
);
const inviteLink = `${frontendUrl}/login/create-account?inviteCode=${invite.code}`;
const emailResult = await sesService.sendInviteEmail(email, inviteLink);

results.push({
email,
success: emailResult.success,
code: invite.code,
error: emailResult.error,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(
{ email, err: message },
"Failed to create or send invite for email",
);
results.push({ email, success: false, error: message });
}
}

const sent = results.filter((r) => r.success).length;
log.info(
{ adminUserId, total: emails.length, sent },
"Batch invite send complete",
);

return {
total: emails.length,
sent,
failed: emails.length - sent,
results,
};
}

/**
* Revoke an invite code
* @param adminUserId Admin user revoking the code
Expand Down
80 changes: 80 additions & 0 deletions server/src/service/ses-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import log from "../utils/logger.js";

function buildInviteEmailHtml(inviteLink: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;font-family:sans-serif;background:#f4f4f4">
<div style="max-width:600px;margin:40px auto;background:#fff;border-radius:8px;overflow:hidden;border:1px solid #e0e0e0">
<div style="background:#000;padding:24px 32px">
<h1 style="margin:0;color:#fff;font-size:20px;font-weight:700">GuardConnect</h1>
</div>
<div style="padding:32px">
<h2 style="margin:0 0 12px;font-size:18px;color:#111">You've been invited</h2>
<p style="margin:0 0 24px;color:#444;font-size:15px;line-height:1.5">
An admin has invited you to join the GuardConnect platform. Click the button below to create your account.
</p>
<a href="${inviteLink}" style="display:inline-block;background:#000;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-size:15px;font-weight:600">
Accept Invitation
</a>
<p style="margin:24px 0 0;color:#888;font-size:12px;word-break:break-all">
Or copy this link into your browser: ${inviteLink}
</p>
</div>
</div>
</body>
</html>`;
}

export class SesService {
private client: SESClient | null = null;
private fromEmail: string | null = null;

constructor() {
const fromEmail = process.env.SES_FROM_EMAIL;
if (!fromEmail) {
log.warn("SES_FROM_EMAIL is not set — invite emails will not be sent");
return;
}
this.fromEmail = fromEmail;
this.client = new SESClient({
region: process.env.AWS_REGION ?? "us-east-1",
});
}

async sendInviteEmail(
toEmail: string,
inviteLink: string,
): Promise<{ success: boolean; error?: string }> {
if (!this.client || !this.fromEmail) {
return { success: false, error: "SES_FROM_EMAIL is not configured" };
}

try {
const result = await this.client.send(
new SendEmailCommand({
Source: this.fromEmail,
Destination: { ToAddresses: [toEmail] },
Message: {
Subject: { Data: "You've been invited to join GuardConnect" },
Body: {
Html: { Data: buildInviteEmailHtml(inviteLink) },
Text: {
Data: `You've been invited to join GuardConnect. Create your account here: ${inviteLink}`,
},
},
},
}),
);
log.info({ toEmail, messageId: result.MessageId }, "Invite email sent");
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn({ toEmail, err: message }, "Failed to send invite email");
return { success: false, error: message };
}
}
}

export const sesService = new SesService();
26 changes: 26 additions & 0 deletions server/src/types/invite-code-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,29 @@ export type InviteCodeStatus = z.infer<typeof inviteCodeStatusEnum>;
export const revokeInviteCodeInputSchema = z.object({
codeId: z.number().int().positive(),
});

// Input/output schemas for batch invite sending
export const sendBatchInvitesInputSchema = z.object({
emails: z
.array(z.string().email("Invalid email address"))
.min(1, "At least one email is required")
.max(50, "Cannot send more than 50 invites at once"),
roleKeys: roleKeysArraySchema,
expiresInHours: z.number().positive().optional(),
});

const batchInviteResultItemSchema = z.object({
email: z.string(),
success: z.boolean(),
code: z.string().optional(),
error: z.string().optional(),
});

export const sendBatchInvitesOutputSchema = z.object({
total: z.number(),
sent: z.number(),
failed: z.number(),
results: z.array(batchInviteResultItemSchema),
});

export type BatchInviteResult = z.infer<typeof sendBatchInvitesOutputSchema>;
Loading
Loading