Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { verifyToken } from "./middleware/auth";
import messageRoutes from "./routes/messageRoutes";
import tagRoutes from "./routes/tagRoutes";
import templateRoutes from "./routes/templateRoutes";
import textJobRoutes from "./routes/textJobRoutes";
import volunteerAssignmentRoutes from "./routes/volunteerAssignmentRoutes";
import volunteerRoutes from "./routes/volunteerRoutes";

Expand Down Expand Up @@ -43,6 +44,7 @@ app.use(
}),
);

app.use("/api/text-jobs", textJobRoutes);
app.use("/api/volunteer", verifyToken, volunteerRoutes);
app.use("/api/volunteerAssignment", verifyToken, volunteerAssignmentRoutes);
app.use("/api/tag", verifyToken, tagRoutes);
Expand Down
56 changes: 56 additions & 0 deletions backend/src/controllers/textJobController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto";

import createError from "http-errors";

import TextJobModel from "../models/textJobModel";

import type { NextFunction, Request, RequestHandler, Response } from "express";

const FIRST_NAME_TOKEN = "{{First Name}}";

type Recipient = {
firstName: string;
phoneNumber: string;
};

type CreateTextJobBody = {
message: string;
recipients: Recipient[];
};

export const createTextJob = async (req: Request, res: Response, next: NextFunction) => {
try {
const { message, recipients } = req.body as CreateTextJobBody;

if (!message || !Array.isArray(recipients) || recipients.length === 0) {
res.status(400).json({ error: "message and recipients are required" });
return;
}

const jobId = randomUUID();
await TextJobModel.create({ jobId, message, recipients });

res.status(201).json({ jobId });
} catch (error) {
next(error);
}
};

export const getTextJob: RequestHandler = async (req, res, next) => {
try {
const job = await TextJobModel.findOne({ jobId: req.params.id });

if (!job) {
throw createError(404, "Job not found or expired");
}

res.status(200).json({
messages: job.recipients.map((r) => ({
to: r.phoneNumber,
body: job.message.replaceAll(FIRST_NAME_TOKEN, r.firstName),
})),
});
} catch (error) {
next(error);
}
};
22 changes: 22 additions & 0 deletions backend/src/models/textJobModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { model, Schema } from "mongoose";

import type { InferSchemaType } from "mongoose";

const recipientSchema = new Schema(
{
firstName: { type: String, required: true },
phoneNumber: { type: String, required: true },
},
{ _id: false },
);

const textJobSchema = new Schema({
jobId: { type: String, required: true, unique: true },
message: { type: String, required: true },
recipients: { type: [recipientSchema], required: true },
createdAt: { type: Date, default: Date.now, expires: 3600 },
});

type TextJob = InferSchemaType<typeof textJobSchema>;

export default model<TextJob>("TextJob", textJobSchema);
11 changes: 11 additions & 0 deletions backend/src/routes/textJobRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import express from "express";

import { createTextJob, getTextJob } from "../controllers/textJobController";
import { verifyToken } from "../middleware/auth";

const router = express.Router();

router.post("/", verifyToken, createTextJob);
router.get("/:id", getTextJob);

export default router;
29 changes: 29 additions & 0 deletions frontend/src/app/api/textJobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { handleAPIError, post } from "./requests";

import type { APIResult } from "./requests";

type TextJobRecipient = {
firstName: string;
phoneNumber: string;
};

type CreateTextJobRequest = {
message: string;
recipients: TextJobRecipient[];
};

type CreateTextJobResponse = {
jobId: string;
};

export const createTextJob = async (
req: CreateTextJobRequest,
): Promise<APIResult<CreateTextJobResponse>> => {
try {
const response = await post("/api/text-jobs", req);
const data = (await response.json()) as CreateTextJobResponse;
return { success: true, data };
} catch (err) {
return handleAPIError(err);
}
};
39 changes: 39 additions & 0 deletions frontend/src/app/messages/new/review/page.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,45 @@
cursor: not-allowed;
}

.shortcutCta {
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
gap: 12px;
}

.shortcutBtn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 56px;
border-radius: 12px;
background: #163c73;
color: #fff;
font-weight: 400;
font-size: 16px;
line-height: 20px;
letter-spacing: 0.5px;
text-decoration: none;
box-sizing: border-box;
}

.doneBtn {
width: 100%;
min-height: 56px;
border-radius: 12px;
border: 1.5px solid #163c73;
background: transparent;
color: #163c73;
font-weight: 400;
font-size: 16px;
line-height: 20px;
letter-spacing: 0.5px;
cursor: pointer;
}

.desktopMain {
display: none;
}
Expand Down
108 changes: 83 additions & 25 deletions frontend/src/app/messages/new/review/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTextingFlowStore } from "../_store/textingFlowStore";
import styles from "./page.module.css";

import { createMessageHistory } from "@/app/api/messages";
import { createTextJob } from "@/app/api/textJobs";
import { fetchVolunteers } from "@/app/api/volunteer";
import backIconAsset from "@/assets/back.svg";
import groupsIconAsset from "@/assets/ic_volunteers_alt.svg";
Expand Down Expand Up @@ -68,6 +69,11 @@ export default function ReviewAndSendPage() {

const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [shortcutUrl, setShortcutUrl] = useState<string | null>(null);

useEffect(() => {
setShortcutUrl(null);
}, [selectedRecipientIds]);

const successMessage = useMemo(() => {
return `Your message has successfully\nbeen sent to ${recipientsCount} volunteers.`;
Expand Down Expand Up @@ -127,6 +133,26 @@ export default function ReviewAndSendPage() {
}

if (mode === "text") {
const allVolunteers = await fetchVolunteers();
const textRecipients = allVolunteers.filter((v) => selectedRecipientIds.includes(v._id));
if (textRecipients.length === 0) {
setSendError("Could not find recipient data. Please re-select your recipients.");
return;
}

const jobResult = await createTextJob({
message,
recipients: textRecipients.map((v) => ({
firstName: v.firstName,
phoneNumber: v.phoneNumber,
})),
});

if (!jobResult.success) {
setSendError(jobResult.error);
return;
}

const historyResult = await createMessageHistory({
recipients: selectedRecipientIds,
type: "text",
Expand All @@ -139,8 +165,19 @@ export default function ReviewAndSendPage() {
setSendError(historyResult.error);
return;
}

const shortcutBase =
process.env.NEXT_PUBLIC_SHORTCUT_API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:4000";
const apiUrl = `${shortcutBase}/api/text-jobs/${jobResult.data.jobId}`;
setShortcutUrl(
`shortcuts://run-shortcut?name=${encodeURIComponent("Send Website Messages")}&input=text&text=${encodeURIComponent(apiUrl)}`,
);
return;
}

// email only reaches here
sessionStorage.setItem("success-toast", successMessage);
resetDraft();
router.replace("/communication");
Expand All @@ -149,6 +186,12 @@ export default function ReviewAndSendPage() {
}
};

const handleDone = () => {
sessionStorage.setItem("success-toast", successMessage);
resetDraft();
router.replace("/communication");
};

const ReviewContent = () => (
<>
{isDesktop ? (
Expand Down Expand Up @@ -275,23 +318,27 @@ export default function ReviewAndSendPage() {

<div className={styles.bottomCta}>
{sendError && <p className={styles.sendError}>{sendError}</p>}
{sendDate && (
<div className={styles.scheduledTime}>
<span>
Scheduled:<b>&nbsp;{formatDate(sendDate)}</b>
</span>
{shortcutUrl ? (
<div className={styles.shortcutCta}>
<a href={shortcutUrl} className={styles.shortcutBtn}>
Open in Shortcuts
</a>
<button type="button" className={styles.doneBtn} onClick={handleDone}>
Done
</button>
</div>
) : (
<button
type="button"
className={styles.sendBtn}
disabled={!canSend || sending}
onClick={() => {
void handleSend();
}}
>
{sending ? "Sending..." : mode === "email" ? "Send Email" : "Send Text"}
</button>
)}
<button
type="button"
className={styles.sendBtn}
disabled={!canSend || sending}
onClick={() => {
void handleSend();
}}
>
{sending ? "Sending..." : mode === "email" ? "Send Email" : "Send Text"}
</button>
</div>
</>
) : (
Expand All @@ -317,16 +364,27 @@ export default function ReviewAndSendPage() {
)}

<div className={styles.desktopSend}>
<button
type="button"
className={styles.sendBtn}
disabled={!canSend || sending}
onClick={() => {
void handleSend();
}}
>
{sending ? "Sending..." : "Review and Send"}
</button>
{shortcutUrl ? (
<div className={styles.shortcutCta}>
<a href={shortcutUrl} className={styles.shortcutBtn}>
Open in Shortcuts
</a>
<button type="button" className={styles.doneBtn} onClick={handleDone}>
Done
</button>
</div>
) : (
<button
type="button"
className={styles.sendBtn}
disabled={!canSend || sending}
onClick={() => {
void handleSend();
}}
>
{sending ? "Sending..." : "Review and Send"}
</button>
)}
</div>
</section>
</div>
Expand Down
17 changes: 9 additions & 8 deletions frontend/src/auth/msal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ export async function initMsal(): Promise<AccountInfo | undefined> {
});
account = result?.account;
} catch (e: unknown) {
if (
typeof e === "object" &&
e !== null &&
"errorCode" in e &&
e.errorCode === "access_denied"
) {
console.info("User cancelled Microsoft login");
return undefined;
if (typeof e === "object" && e !== null && "errorCode" in e) {
if (e.errorCode === "access_denied") {
console.info("User cancelled Microsoft login");
return undefined;
}
if (e.errorCode === "no_token_request_cache_error") {
// Not coming from a Microsoft login redirect — expected on direct navigation
return undefined;
}
}
console.error("MSAL Redirect Error:", e);
return undefined;
Expand Down
Loading