Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an
Inbox with sender name, color, Read/Unread badge, and inline reply. Sender
sees Sent Notes with per-recipient status. Sender's and recipient's copies
must be INDEPENDENT, with backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.
Two prior code-review rounds were addressed in this commit:
Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from note_recipients.note_id and note_replies.note_id so
recipient threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot for
recipients (sender/admin still see the live note).
Round 2 (validation REJECT fixes):
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
do not change recipient status; recipient replies still flip to
"replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
/notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
/notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
useReceivedNotes(false) filtered to status === "unread"; refreshes
automatically on socket invalidation.
Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.
Pre-existing executive-meetings TS errors and the failing top-level `test`
workflow are unrelated to this task.
This commit is contained in:
@@ -16,6 +16,8 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { notificationPlayer } from "@/lib/notification-sounds";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
@@ -103,12 +105,31 @@ export function useNotificationsSocket() {
|
||||
});
|
||||
});
|
||||
|
||||
// Realtime note events: invalidate notes queries AND surface a toast so
|
||||
// the recipient (or sender, on a reply) sees an immediate, dismissible
|
||||
// confirmation regardless of which page they're on.
|
||||
socket.on("note_received", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
||||
const ar = i18n.language === "ar";
|
||||
toast({
|
||||
title: ar ? "ملاحظة جديدة" : "New note received",
|
||||
description: ar
|
||||
? "تحقق من بريد الملاحظات الوارد لقراءتها."
|
||||
: "Check your Notes inbox to read it.",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("note_replied", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
||||
const ar = i18n.language === "ar";
|
||||
toast({
|
||||
title: ar ? "رد جديد على ملاحظة" : "New reply on a note",
|
||||
description: ar
|
||||
? "افتح الملاحظة لمتابعة المحادثة."
|
||||
: "Open the note to continue the conversation.",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("note_status_changed", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useReceivedNotes } from "@/lib/notes-api";
|
||||
import {
|
||||
Bell,
|
||||
Inbox,
|
||||
@@ -94,7 +95,13 @@ function gradientForColor(color: string): string {
|
||||
return "icon-tile-blue";
|
||||
}
|
||||
|
||||
function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) {
|
||||
function AppIconContent({
|
||||
app,
|
||||
badgeCount = 0,
|
||||
}: {
|
||||
app: { nameAr: string; nameEn: string; iconName: string; color: string };
|
||||
badgeCount?: number;
|
||||
}) {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
const lang = i18nInstance.language;
|
||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
@@ -103,8 +110,13 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||
<div className={`relative icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
|
||||
{badgeCount > 9 ? "9+" : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
||||
{name}
|
||||
@@ -113,7 +125,15 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa
|
||||
);
|
||||
}
|
||||
|
||||
function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
|
||||
function SortableAppIcon({
|
||||
app,
|
||||
onClick,
|
||||
badgeCount = 0,
|
||||
}: {
|
||||
app: App;
|
||||
onClick: () => void;
|
||||
badgeCount?: number;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -132,7 +152,7 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<AppIconContent app={app} />
|
||||
<AppIconContent app={app} badgeCount={badgeCount} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -409,6 +429,13 @@ export default function HomePage() {
|
||||
const pendingOrdersCount =
|
||||
incomingOrders?.filter((o) => o.status === "pending" && !o.assignedTo).length ?? 0;
|
||||
|
||||
// Unread Notes badge — drives the count shown on the Notes app tile (and
|
||||
// dock entry, if present). We reuse the existing /notes/received hook so
|
||||
// socket invalidations on note_received automatically refresh this.
|
||||
const { data: receivedNotes } = useReceivedNotes(false);
|
||||
const unreadNotesCount =
|
||||
receivedNotes?.filter((n) => n.status === "unread").length ?? 0;
|
||||
|
||||
const logout = useLogout();
|
||||
const updateLanguage = useUpdateLanguage();
|
||||
|
||||
@@ -611,6 +638,7 @@ export default function HomePage() {
|
||||
key={app.id}
|
||||
app={app}
|
||||
onClick={() => openApp(app)}
|
||||
badgeCount={app.slug === "notes" ? unreadNotesCount : 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user