From 6b8a222d6be9b51872782ff464bc7615e1ef1122 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Tue, 26 May 2026 12:06:43 -0600 Subject: [PATCH] feat(jsn-bridge): notifications payload + embedded-mode polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions on top of the existing JSN-bridge work — both surface through to JSN's /account VolunteerHub without further coordination. api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts: - Add DashboardNotification interface (id, title, body?, kind?, createdAt, href?) and `notifications: DashboardNotification[]` on VolunteerDashboardPayload. - getDashboard() now fans out an 8th parallel fetch to the existing notificationService.listNotifications(userId, 1, 20, true), mapping each row's metadata.href (if present) into the href field. - JSN's NotificationsCard was shipped pre-emptively typed for this field (loose-schema-now); it lights up automatically once cmlite is redeployed with this commit. admin/src/components/VolunteerLayout.tsx + VolunteerFooterNav.tsx: - Suppress the "Welcome to the Volunteer Portal!" alert when ?layout=embedded — the supporter arriving via SSO already knows where they are; the alert is noise. - Add a "← Back to JSN" link in the drawer cross-nav section, visible only when embedded. Reads VITE_JSN_HOME_URL (new env var, defaults to http://localhost:8085 for dev). - Preserve ?layout=embedded across in-cmlite navigation. Both the drawer nav and the footer nav now route through a navigateInLayout helper that appends the query when the supporter arrived embedded. Without this fix, clicking the footer "Map" button (etc.) stripped the query and re-showed PublicNavBar + the welcome alert. admin/src/vite-env.d.ts: - New VITE_JSN_HOME_URL slot in ImportMetaEnv. Default dev value baked into VolunteerLayout's render; production overrides via .env. Bunker Admin --- admin/src/components/VolunteerFooterNav.tsx | 17 ++++- admin/src/components/VolunteerLayout.tsx | 44 ++++++++++-- admin/src/vite-env.d.ts | 5 ++ .../volunteer-dashboard.service.ts | 68 ++++++++++++++++--- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/admin/src/components/VolunteerFooterNav.tsx b/admin/src/components/VolunteerFooterNav.tsx index 01a9fb5..7ddb47e 100644 --- a/admin/src/components/VolunteerFooterNav.tsx +++ b/admin/src/components/VolunteerFooterNav.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { theme } from 'antd'; import { HomeOutlined, @@ -32,8 +32,21 @@ interface VolunteerFooterNavProps { export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) { const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); const { token } = theme.useToken(); const { settings } = useSettingsStore(); + // Preserve ?layout=embedded across in-cmlite footer nav so chrome stays + // hidden after the supporter clicks through from JSN. Matches the helper + // in VolunteerLayout's drawer nav. + const isEmbedded = searchParams.get('layout') === 'embedded'; + const navigateInLayout = (to: string) => { + if (isEmbedded) { + const sep = to.includes('?') ? '&' : '?'; + navigate(`${to}${sep}layout=embedded`); + } else { + navigate(to); + } + }; const NAV_ITEMS = useMemo(() => { const items = [...BASE_NAV_ITEMS]; @@ -100,7 +113,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal return (
navigate(key)} + onClick={() => navigateInLayout(key)} style={{ display: 'flex', alignItems: 'center', diff --git a/admin/src/components/VolunteerLayout.tsx b/admin/src/components/VolunteerLayout.tsx index d9b4d04..8afdd3c 100644 --- a/admin/src/components/VolunteerLayout.tsx +++ b/admin/src/components/VolunteerLayout.tsx @@ -16,6 +16,7 @@ import { TeamOutlined, MessageOutlined, BarChartOutlined, + ArrowLeftOutlined, } from '@ant-design/icons'; import { useAuthStore } from '@/stores/auth.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -53,6 +54,21 @@ export default function VolunteerLayout() { navigate('/login', { replace: true }); }; + // Navigate while preserving the ?layout=embedded query so chrome stays + // hidden as the supporter moves through cmlite. Without this, clicking any + // drawer/footer-nav link would strip the param and re-show PublicNavBar + + // the welcome alert on the next page. + const navigateInLayout = (to: string) => { + if (isEmbedded) { + const sep = to.includes('?') ? '&' : '?'; + navigate(`${to}${sep}layout=embedded`); + } else { + navigate(to); + } + }; + + const jsnHomeUrl = import.meta.env.VITE_JSN_HOME_URL ?? 'http://localhost:8085'; + // Build nav items list (mirrors VolunteerFooterNav logic) const navItems = useMemo(() => { const items: { key: string; icon: React.ReactNode; label: string }[] = [ @@ -115,7 +131,7 @@ export default function VolunteerLayout() { padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px', }} > - {!welcomeDismissed && ( + {!isEmbedded && !welcomeDismissed && ( { navigate(key); setMenuOpen(false); }} + onClick={() => { navigateInLayout(key); setMenuOpen(false); }} style={{ display: 'flex', alignItems: 'center', @@ -222,8 +238,28 @@ export default function VolunteerLayout() { {/* Cross-navigation links */}
+ {isEmbedded && ( + setMenuOpen(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 20px', + cursor: 'pointer', + color: 'rgba(255,255,255,0.85)', + fontSize: 14, + fontWeight: 500, + textDecoration: 'none', + }} + > + + Back to JSN + + )}
{ navigate('/home'); setMenuOpen(false); }} + onClick={() => { navigateInLayout('/home'); setMenuOpen(false); }} style={{ display: 'flex', alignItems: 'center', @@ -239,7 +275,7 @@ export default function VolunteerLayout() {
{isAdmin && (
{ navigate('/app'); setMenuOpen(false); }} + onClick={() => { navigateInLayout('/app'); setMenuOpen(false); }} style={{ display: 'flex', alignItems: 'center', diff --git a/admin/src/vite-env.d.ts b/admin/src/vite-env.d.ts index 5049e93..955dfe5 100644 --- a/admin/src/vite-env.d.ts +++ b/admin/src/vite-env.d.ts @@ -6,6 +6,11 @@ interface ImportMetaEnv { readonly VITE_MKDOCS_URL?: string; readonly VITE_DOMAIN?: string; readonly VITE_MKDOCS_SITE_PORT?: string; + // Home URL for the upstream "Just Say No" supporter site. Used by + // VolunteerLayout's "← Back to JSN" link in embedded mode. Defaults to + // http://localhost:8085 in dev; production deploys set this to the public + // JSN host (e.g. https://justsaynoab.ca). + readonly VITE_JSN_HOME_URL?: string; } interface ImportMeta { diff --git a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts index 36ee19d..2727d8e 100644 --- a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts +++ b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts @@ -3,6 +3,7 @@ import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { actionCampaignsService, type ActiveCampaignForUser } from '../action-campaigns/action-campaigns.service'; import { referralService } from '../social/referral.service'; +import { notificationService } from '../social/notification.service'; export interface DashboardProfile { id: string; @@ -67,6 +68,19 @@ export interface DashboardResource { viewPath: string | null; } +// Unread-notification summary surfaced to bridged callers (JSN's VolunteerHub +// renders this as a "N unread" badge + top-3 titles + "View all →"). Field +// shape is intentionally minimal: callers don't need the full Notification +// row, just enough to render a list + open the source on click. +export interface DashboardNotification { + id: string; + title: string; + body?: string; + kind?: string; + createdAt: string; + href?: string; +} + export interface VolunteerDashboardPayload { profile: DashboardProfile; referral: DashboardReferral; @@ -76,6 +90,7 @@ export interface VolunteerDashboardPayload { myEvents: DashboardMyEvent[]; points: DashboardPoints; resources: DashboardResource[]; + notifications: DashboardNotification[]; } const RESOURCE_TAG = 'volunteer-resource'; @@ -311,20 +326,54 @@ async function getResources(): Promise { return items.slice(0, 8).map(({ sortKey: _sortKey, ...rest }) => rest); } +// Map Prisma Notification row → DashboardNotification. id is coerced to +// string (Prisma uses int autoincrement; JSN renders it as a React key so +// string is friendlier across the bridge). `metadata.href` is the per-row +// deep-link cmlite owns; missing values are fine. +function toDashboardNotification(n: { + id: number; + type: string; + title: string; + message: string; + metadata: Prisma.JsonValue; + createdAt: Date; +}): DashboardNotification { + let href: string | undefined; + if (n.metadata && typeof n.metadata === 'object' && !Array.isArray(n.metadata)) { + const raw = (n.metadata as Record).href; + if (typeof raw === 'string') href = raw; + } + return { + id: String(n.id), + title: n.title, + body: n.message, + kind: n.type, + createdAt: n.createdAt.toISOString(), + href, + }; +} + +async function getNotifications(userId: string): Promise { + const result = await notificationService.listNotifications(userId, 1, 20, true); + return result.notifications.map(toDashboardNotification); +} + export const volunteerDashboardService = { async getDashboard(userId: string): Promise { const profile = await getProfile(userId); if (!profile) return null; - const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources] = await Promise.all([ - getReferral(userId), - actionCampaignsService.getActiveForUser(userId), - getFeaturedEvent(), - getTrainings(userId), - getMyEvents(userId), - getPoints(userId), - getResources(), - ]); + const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources, notifications] = + await Promise.all([ + getReferral(userId), + actionCampaignsService.getActiveForUser(userId), + getFeaturedEvent(), + getTrainings(userId), + getMyEvents(userId), + getPoints(userId), + getResources(), + getNotifications(userId), + ]); return { profile, @@ -335,6 +384,7 @@ export const volunteerDashboardService = { myEvents, points, resources, + notifications, }; }, };