Skip to content
Open
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
9 changes: 9 additions & 0 deletions backend/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ service cloud.firestore {
resource.data.senderId != request.auth.uid;
allow delete: if false;
}

match /transcriptExports/{exportId} {
// Written by the Admin SDK via the recordTranscriptExport callable
// only, never directly by a client. Staff-only read keeps the
// export audit trail (who exported, when, what range) invisible to
// patients, who have no reason to see it.
allow read: if isStaff();
allow create, update, delete: if false;
}
}

match /checklists/{uid}/user_checklists/{docId} {
Expand Down
4 changes: 3 additions & 1 deletion backend/functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export {
setStaffDisabled,
} from "./admin";

export { calendarIcs } from "./calendar";
export {calendarIcs} from "./calendar";

export {recordTranscriptExport} from "./staff";
44 changes: 38 additions & 6 deletions backend/functions/src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function hasHeader(request: CallableRequest<unknown>, name: string): boolean {
return typeof value === "string" && value.length > 0;
}

function logCallableRequest(
export function logCallableRequest(
callable: string,
request: CallableRequest<unknown>): void {
logger.info("Callable request received", {
Expand All @@ -119,7 +119,7 @@ function logCallableRequest(
});
}

function requireAuthUid(uid?: string): string {
export function requireAuthUid(uid?: string): string {
if (!uid) {
throw new HttpsError("unauthenticated", "You must be signed in.");
}
Expand Down Expand Up @@ -229,7 +229,7 @@ async function findChatParticipant(patientId: string): Promise<string> {
"No available user was found to create a chat.");
}

async function assertCanSendMessage(uid: string, chatId: string): Promise<void> {
export async function assertChatParticipant(uid: string, chatId: string): Promise<void> {
const chatDoc = await firestore.collection("chats").doc(chatId).get();
if (!chatDoc.exists) {
throw new HttpsError("not-found", "Chat not found.");
Expand All @@ -243,6 +243,38 @@ async function assertCanSendMessage(uid: string, chatId: string): Promise<void>
}
}

// Mirrors isOwnChatOrParticipant() in firestore.rules: the patient owns the
// chat at their own uid, any staff member may reach any chat, and anyone
// listed on the chat doc is a participant. Deliberately kept as loose as the
// read rules. Note `participants` only picks up a social worker once they
// have SENT a message, so it cannot stand in for a staff check.
export async function assertCanAccessChat(
uid: string, chatId: string): Promise<void> {
const chatDoc = await firestore.collection("chats").doc(chatId).get();
if (!chatDoc.exists) {
throw new HttpsError("not-found", "Chat not found.");
}

if (uid === chatId) {
return;
}

const participants = chatDoc.data()?.participants;
if (Array.isArray(participants) && participants.includes(uid)) {
return;
}

const role = (await firestore.collection("users").doc(uid).get()).get("role");
// Both spellings are in the wild; findChatParticipant tolerates the same.
if (role === "social_worker" || role === "socialWorker" || role === "admin") {
return;
}

throw new HttpsError(
"permission-denied",
"You do not have access to this chat.");
}

export const onAuthUserCreated = functions.auth.user().onCreate(async (user) => {
const fallbackNameParts = splitName(user.displayName ?? user.email);
const firstName = fallbackNameParts.firstName;
Expand Down Expand Up @@ -299,7 +331,7 @@ export const createUserChat = onCall(publicCallableOptions, async (request) => {
participants: [uid, otherUserId],
lastMessage: "",
lastMessageTimestamp: serverTimestamp(),
});
}, {merge: true});

logger.info("createUserChat created chat", {uid, otherUserId});
return {chatId: uid};
Expand Down Expand Up @@ -327,7 +359,7 @@ export const sendChatMessage = onCall(
"chatId and content are required.");
}

await assertCanSendMessage(uid, chatId);
await assertChatParticipant(uid, chatId);

const messagesRef = firestore
.collection("chats")
Expand Down Expand Up @@ -396,7 +428,7 @@ export const sendChatImageMessage = onCall(
"Image path does not belong to this chat.");
}

await assertCanSendMessage(uid, chatId);
await assertChatParticipant(uid, chatId);

const messagesRef = firestore
.collection("chats")
Expand Down
148 changes: 148 additions & 0 deletions backend/functions/src/staff/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as admin from "firebase-admin";
import {HttpsError, onCall} from "firebase-functions/v2/https";
import {logger} from "firebase-functions";
import {
assertCanAccessChat,
logCallableRequest,
requireAuthUid,
} from "../shared";

// admin may already be initialized by another module in this codebase.
if (admin.apps.length === 0) {
admin.initializeApp();
}

const firestore = admin.firestore();
const serverTimestamp = admin.firestore.FieldValue.serverTimestamp;

type Timestamp = admin.firestore.Timestamp;

interface RecordTranscriptExportRequest {
chatId?: unknown;
firstMessageId?: unknown;
lastMessageId?: unknown;
}

function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

// recordTranscriptExport — records a watermark + audit trail entry each time
// a chat transcript is exported.
//
// `lastTranscriptExportedAt` on the chat doc is the timestamp of the last
// MESSAGE ever included in an export; it only ever moves forward (see the
// monotonic guard below) so re-exporting an older range can't regress it.
// `lastTranscriptExportedRunAt` is a separate field for when the export was
// last run, regardless of range.
//
// Input: { chatId, firstMessageId, lastMessageId }
// Output: { lastTranscriptExportedAtMs, messageCount }
export const recordTranscriptExport = onCall(async (request) => {
logCallableRequest("recordTranscriptExport", request);
const uid = requireAuthUid(request.auth?.uid);

const data = request.data as RecordTranscriptExportRequest | undefined;
const chatId = stringValue(data?.chatId);
const firstMessageId = stringValue(data?.firstMessageId);
const lastMessageId = stringValue(data?.lastMessageId);

if (!chatId || !firstMessageId || !lastMessageId) {
throw new HttpsError(
"invalid-argument",
"chatId, firstMessageId, and lastMessageId are required.");
}

await assertCanAccessChat(uid, chatId);

const chatRef = firestore.collection("chats").doc(chatId);
const messagesRef = chatRef.collection("messages");

const [firstDoc, lastDoc] = await Promise.all([
messagesRef.doc(firstMessageId).get(),
messagesRef.doc(lastMessageId).get(),
]);

if (!firstDoc.exists) {
throw new HttpsError(
"not-found",
"firstMessageId was not found in this chat.");
}
if (!lastDoc.exists) {
throw new HttpsError(
"not-found",
"lastMessageId was not found in this chat.");
}

const firstTimestamp = firstDoc.get("timestamp") as Timestamp | undefined;
const lastTimestamp = lastDoc.get("timestamp") as Timestamp | undefined;

if (!firstTimestamp || !lastTimestamp) {
throw new HttpsError(
"invalid-argument",
"Both firstMessageId and lastMessageId must have a timestamp.");
}

if (firstTimestamp.toMillis() > lastTimestamp.toMillis()) {
throw new HttpsError(
"invalid-argument",
"firstMessageId must not be newer than lastMessageId.");
}

// messageCount doesn't feed the watermark read-modify-write below and
// doesn't need to be transactionally consistent with it, so it's read as
// a plain aggregate beforehand. (Admin SDK transactions CAN run aggregate
// queries via transaction.get(), but there's no reason to pay for that
// here — it would only widen the transaction's read set and retry surface
// for no benefit, since nothing in step 5 depends on this count.)
const countSnapshot = await messagesRef
.where("timestamp", ">=", firstTimestamp)
.where("timestamp", "<=", lastTimestamp)
.count()
.get();
const messageCount = countSnapshot.data().count;

const exportedByEmail = request.auth?.token?.email ?? "";

const lastTranscriptExportedAtMs = await firestore.runTransaction(
async (transaction) => {
const chatSnapshot = await transaction.get(chatRef);
const storedWatermark = chatSnapshot.get(
"lastTranscriptExportedAt") as Timestamp | undefined;

// THE correctness property of this function: exporting an older
// range must never drag the watermark backwards.
const nextWatermark =
!storedWatermark || lastTimestamp.toMillis() > storedWatermark.toMillis() ?
lastTimestamp :
storedWatermark;

transaction.set(chatRef, {
lastTranscriptExportedAt: nextWatermark,
lastTranscriptExportedBy: uid,
lastTranscriptExportedRunAt: serverTimestamp(),
}, {merge: true});

const exportRef = chatRef.collection("transcriptExports").doc();
transaction.set(exportRef, {
exportedByUid: uid,
exportedByEmail,
exportedAt: serverTimestamp(),
rangeStart: firstTimestamp,
rangeEnd: lastTimestamp,
messageCount,
lastMessageId,
});

return nextWatermark.toMillis();
});

logger.info("recordTranscriptExport committed", {
uid,
chatId,
messageCount,
lastTranscriptExportedAtMs,
});

return {lastTranscriptExportedAtMs, messageCount};
});
47 changes: 43 additions & 4 deletions web/app/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import type { UIEvent } from "react";
import { useState, type UIEvent } from "react";
import { ClipboardCopy } from "lucide-react";
import MessageBubble from "~/components/MessageBubble";
import ChatComposer from "~/components/ChatComposer";
import TranscriptExportDialog from "~/components/TranscriptExportDialog";
import { useChat } from "~/hooks/useChat";
import { formatTranscriptTimestamp } from "~/services/transcript_format";

interface ChatPanelProps {
userId: string;
chatUserFullName: string;
currentUserName: string;
patientEmail: string;
currentUserEmail: string;
currentUserId: string;
}

/** Chat column: heading, scrollable message list, and composer. */
export default function ChatPanel({
userId,
chatUserFullName,
currentUserName,
patientEmail,
currentUserEmail,
currentUserId,
}: ChatPanelProps) {
const [exportOpen, setExportOpen] = useState(false);
const {
messages,
newMessage,
Expand All @@ -25,6 +35,8 @@ export default function ChatPanel({
isSending,
isLoadingMessages,
isLoadingOlder,
lastExportedAtMs,
setLastExportedAtMs,
senderProfiles,
messageContainerRef,
fileInputRef,
Expand All @@ -43,9 +55,24 @@ export default function ChatPanel({

return (
<div className="flex w-[50vw] min-w-[360px] max-w-[600px] flex-col">
<h2 className="mb-3 text-right text-[24px] font-semibold leading-tight text-black">
Chat with {chatUserFullName}
</h2>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-[24px] font-semibold leading-tight text-black">
Chat with {chatUserFullName}
</h2>
<button
type="button"
onClick={() => setExportOpen(true)}
className="flex shrink-0 items-center gap-2 rounded-full bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#333333]"
>
<ClipboardCopy size={16} />
Export transcript
</button>
</div>
<p className="mb-3 text-xs text-[#999999]">
{lastExportedAtMs
? `Last exported ${formatTranscriptTimestamp(lastExportedAtMs)}`
: "Never exported"}
</p>

<section className="flex flex-1 flex-col overflow-hidden bg-white p-6 shadow-[0_4px_12px_rgba(0,0,0,0.12)]">
<div
Expand Down Expand Up @@ -121,6 +148,18 @@ export default function ChatPanel({
sendMessage={sendMessage}
/>
</section>

<TranscriptExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
chatId={userId}
patientName={chatUserFullName}
patientEmail={patientEmail}
currentUserEmail={currentUserEmail}
currentUserId={currentUserId}
lastExportedAtMs={lastExportedAtMs}
onExported={setLastExportedAtMs}
/>
</div>
);
}
Loading