diff --git a/CLAUDE.md b/CLAUDE.md index c43d3219..a3693ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -339,6 +339,33 @@ cd api && ./test-media-api.sh cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit ``` +### API Testing Credentials & Login + +**Test admin account:** `admin@bnkops.ca` / `ChangeMe2025!` (SUPER_ADMIN role) + +**Reliable login method (avoids shell `!` escaping issues):** + +1. Write the JSON body to a file using the **Write tool** (NOT echo/printf — the `!` gets backslash-escaped by bash): + ``` + Write /tmp/login.json → {"email":"admin@bnkops.ca","password":"ChangeMe2025!"} + ``` +2. Use `curl -d @/tmp/login.json`: + ```bash + curl -s -X POST http://localhost:4002/api/auth/login \ + -H "Content-Type: application/json" -d @/tmp/login.json + ``` +3. Extract token and use for authenticated requests: + ```bash + TOKEN=$(curl -s -X POST http://localhost:4002/api/auth/login \ + -H "Content-Type: application/json" -d @/tmp/login.json \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])") + curl -s http://localhost:4002/api/some-endpoint -H "Authorization: Bearer $TOKEN" + ``` + +**Port mapping:** API container port 4000 → host port **4002**, Admin port 3000 → host port **3002** + +**Important:** The `!` character in `ChangeMe2025!` triggers bash history expansion. NEVER pass this password directly in bash command strings. Always use the Write-tool-to-file approach above. + --- ## Core Modules Reference diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 30a347a2..dccd9742 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -81,6 +81,7 @@ import MyRoutesPage from '@/pages/volunteer/MyRoutesPage'; import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage'; import { ADMIN_ROLES } from '@/types/api'; import { isAdmin } from '@/utils/roles'; +import QuickJoinPage from '@/pages/public/QuickJoinPage'; import VerifyEmailPage from '@/pages/VerifyEmailPage'; import ResetPasswordPage from '@/pages/ResetPasswordPage'; @@ -248,6 +249,7 @@ export default function App() { element={} /> + } /> } /> } /> } /> diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index e61bade0..afd0f446 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -28,7 +28,7 @@ import { BranchesOutlined, CloudServerOutlined, QrcodeOutlined, - VideoCameraOutlined, + PlaySquareOutlined, FolderOutlined, HistoryOutlined, LineChartOutlined, @@ -129,10 +129,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS if (settings?.enableMediaFeatures !== false) { items.push({ key: 'media-submenu', - icon: , - label: 'Media Library', + icon: , + label: 'Media', children: [ - { key: '/app/media/library', icon: , label: 'Videos' }, + { key: '/app/media/library', icon: , label: 'Library' }, { key: '/app/media/analytics', icon: , label: 'Analytics' }, { key: '/app/media/curated', icon: , label: 'Curated' }, { key: '/app/media/moderation', icon: , label: 'Moderation' }, @@ -444,10 +444,10 @@ export default function AppLayout() { )} {settings?.enableMediaFeatures !== false && ( - + + ) + } + style={{ marginBottom: 16 }} + /> + + + )} + + + {user?.name || user?.email || 'Volunteer'} + + + {user?.email} + + + {/* Mini stats */} + {stats && ( + + + + + )} + + + + {/* Assignments (hidden when session active) */} + {!sessionActive && assignments.length > 0 && ( + <> + + My Assignments + + ( + } + onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }} + > + Start + , + ]} + > + {a.cutName}} + description={ + + {a.shiftTitle} · {Math.round(a.completionPercentage)}% + + } + /> + + )} + /> + + + )} + + {/* Free session — pick a cut (hidden when session active) */} + {!sessionActive && ( + <> + + Start Session (Any Cut) + + + ({ label: c.name, value: c.id }))} - allowClear - /> - - - - )} - - {/* Navigation links */} - - -
- - - -
- + + + ); } diff --git a/admin/src/components/dashboard/ActivityFeedCard.tsx b/admin/src/components/dashboard/ActivityFeedCard.tsx index 43a093a4..0765af5d 100644 --- a/admin/src/components/dashboard/ActivityFeedCard.tsx +++ b/admin/src/components/dashboard/ActivityFeedCard.tsx @@ -18,11 +18,11 @@ dayjs.extend(relativeTime); const { Text } = Typography; const TYPE_CONFIG: Record = { - shift_signup: { color: '#eb2f96', icon: }, - response_submitted: { color: '#faad14', icon: }, - canvass_completed: { color: '#52c41a', icon: }, - email_sent: { color: '#1890ff', icon: }, - user_created: { color: '#722ed1', icon: }, + shift_signup: { color: '#eb2f96', icon: }, + response_submitted: { color: '#faad14', icon: }, + canvass_completed: { color: '#52c41a', icon: }, + email_sent: { color: '#1890ff', icon: }, + user_created: { color: '#722ed1', icon: }, }; const MODULE_OPTIONS = [ @@ -37,19 +37,19 @@ function ActivityRow({ item }: { item: ActivityItem }) { return ( - {config.icon} - {item.title} + {config.icon} + {item.title} {item.description} - + {dayjs(item.timestamp).fromNow(true)} @@ -77,7 +77,7 @@ export default function ActivityFeedCard() { setLoading(true); try { const res = await api.get('/dashboard/activity', { - params: { page: p, limit: 10, module: mod }, + params: { page: p, limit: 15, module: mod }, }); if (append) { setItems(prev => [...prev, ...res.data.items]); @@ -107,7 +107,7 @@ export default function ActivityFeedCard() { return ( Recent Activity} + title={Recent Activity} size="small" extra={ } - styles={{ body: { padding: '4px 12px 6px' } }} - style={{ height: '100%' }} + styles={{ body: { padding: '6px 14px 8px' } }} > {loading && items.length === 0 ? ( -
+
) : items.length === 0 ? ( - No recent activity + No recent activity ) : ( <> -
+
{items.map(item => ( ))}
{hasMore && ( -
-
diff --git a/admin/src/components/dashboard/CampaignEffectivenessCard.tsx b/admin/src/components/dashboard/CampaignEffectivenessCard.tsx new file mode 100644 index 00000000..f046281c --- /dev/null +++ b/admin/src/components/dashboard/CampaignEffectivenessCard.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Card, Typography, Spin, Flex, Button, Statistic, Tag, Tooltip } from 'antd'; +import { + FundOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; +import type { CampaignOverviewStats } from '@/types/api'; + +const { Text } = Typography; + +export default function CampaignEffectivenessCard() { + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/influence/effectiveness/overview'); + setData(res.data); + setHasError(false); + } catch { + setHasError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 5 * 60_000); + return () => clearInterval(interval); + }, [fetchData]); + + if (hasError && !data) return null; + + const s = data?.summary; + const topCampaigns = data?.campaigns?.slice(0, 3) || []; + + return ( + Campaign Effectiveness} + size="small" + extra={ + + + + + + + + )} +
+ } + description={ +
+ {album.photoCount || album._count?.photos || 0} photos + {album.viewCount > 0 ? ` · ${album.viewCount} views` : ''} +
+ } + /> +
+ ); +} diff --git a/admin/src/components/media/AlbumDetailDrawer.tsx b/admin/src/components/media/AlbumDetailDrawer.tsx new file mode 100644 index 00000000..8da93488 --- /dev/null +++ b/admin/src/components/media/AlbumDetailDrawer.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect } from 'react'; +import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty } from 'antd'; +import { + DeleteOutlined, + PictureOutlined, + CrownOutlined, + GlobalOutlined, +} from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import { getAuthCallbacks } from '@/lib/api'; +import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media'; + +/** Append JWT access token as query param for src URLs */ +function getAuthenticatedUrl(url: string): string { + const { getTokens } = getAuthCallbacks(); + const { accessToken } = getTokens(); + if (!accessToken) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}token=${accessToken}`; +} + +interface AlbumDetailDrawerProps { + albumId: number | null; + open: boolean; + onClose: () => void; + onRefresh: () => void; +} + +export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) { + const [album, setAlbum] = useState(null); + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(false); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + useEffect(() => { + if (albumId && open) { + fetchAlbum(); + } + }, [albumId, open]); + + const fetchAlbum = async () => { + if (!albumId) return; + setLoading(true); + try { + const { data } = await mediaApi.get(`/albums/${albumId}`); + setAlbum(data); + setTitle(data.title); + setDescription(data.description || ''); + } catch { + message.error('Failed to load album'); + } finally { + setLoading(false); + } + }; + + const handleSaveMetadata = async () => { + if (!albumId) return; + try { + await mediaApi.patch(`/albums/${albumId}`, { title, description }); + message.success('Album updated'); + setEditing(false); + fetchAlbum(); + onRefresh(); + } catch { + message.error('Failed to update album'); + } + }; + + const handleSetCover = async (photoId: number) => { + if (!albumId) return; + try { + await mediaApi.put(`/albums/${albumId}/cover`, { photoId }); + message.success('Cover photo set'); + fetchAlbum(); + onRefresh(); + } catch { + message.error('Failed to set cover photo'); + } + }; + + const handleRemovePhoto = async (photoId: number) => { + if (!albumId) return; + try { + await mediaApi.delete(`/albums/${albumId}/photos/${photoId}`); + message.success('Photo removed from album'); + fetchAlbum(); + onRefresh(); + } catch { + message.error('Failed to remove photo'); + } + }; + + const handlePublish = async () => { + if (!albumId) return; + try { + await mediaApi.post(`/albums/${albumId}/publish`); + message.success('Album and photos published'); + fetchAlbum(); + onRefresh(); + } catch { + message.error('Failed to publish album'); + } + }; + + const handleDeleteAlbum = async () => { + if (!albumId) return; + try { + await mediaApi.delete(`/albums/${albumId}`); + message.success('Album deleted (photos preserved)'); + onClose(); + onRefresh(); + } catch { + message.error('Failed to delete album'); + } + }; + + const photos = album?.photos || []; + + return ( + + + + + + {!album?.isPublished && ( + + )} + + + } + > + {/* Editable title/description */} +
+ {editing ? ( + + setTitle(e.target.value)} placeholder="Album title" /> + setDescription(e.target.value)} placeholder="Description" rows={2} /> + + + + + + ) : ( +
+
+
+ {album?.title} + {album?.isPublished && Published} +
+ +
+ {album?.description &&
{album.description}
} +
+ {photos.length} photos · {album?.viewCount || 0} views · {album?.upvoteCount || 0} upvotes +
+
+ )} +
+ + {/* Photo list */} + {photos.length === 0 ? ( + + ) : ( + ( + } + onClick={() => handleSetCover(photo.id)} + type={album?.coverPhotoId === photo.id ? 'primary' : 'default'} + > + {album?.coverPhotoId === photo.id ? 'Cover' : 'Set Cover'} + , + handleRemovePhoto(photo.id)} + > + + + + } + > +
+ + + + + + + + + + + + + + + + + + +
+ + {/* EXIF Info (read-only) */} + {(photo.cameraMake || photo.cameraModel || photo.takenAt) && ( + + {photo.cameraMake && ( + {photo.cameraMake} {photo.cameraModel || ''} + )} + {photo.focalLength && ( + {photo.focalLength} + )} + {photo.aperture && ( + {photo.aperture} + )} + {photo.shutterSpeed && ( + {photo.shutterSpeed} + )} + {photo.iso && ( + {photo.iso} + )} + {photo.takenAt && ( + {new Date(photo.takenAt).toLocaleString()} + )} + {photo.gpsLatitude && photo.gpsLongitude && ( + + {photo.gpsLatitude.toFixed(6)}, {photo.gpsLongitude.toFixed(6)} + + )} + + )} + + {/* Metadata */} + + + {photo.width}×{photo.height} ({photo.orientation}) + + + {photo.format?.toUpperCase()} + {photo.hasAlpha ? ' (alpha)' : ''} + + {photo.colorSpace && ( + {photo.colorSpace} + )} + {photo.dpi && ( + {photo.dpi} + )} + + {photo.originalFilename} + + +
+ ); +} diff --git a/admin/src/components/media/ExpandedAlbumCard.tsx b/admin/src/components/media/ExpandedAlbumCard.tsx new file mode 100644 index 00000000..0fb77d04 --- /dev/null +++ b/admin/src/components/media/ExpandedAlbumCard.tsx @@ -0,0 +1,347 @@ +import { useRef, useState, useEffect } from 'react'; +import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd'; +import { + CloseOutlined, + LikeOutlined, + LikeFilled, + EyeOutlined, + LeftOutlined, + RightOutlined, + PictureOutlined, + AppstoreOutlined, +} from '@ant-design/icons'; +import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; +import { mediaPublicApi } from '@/lib/media-public-api'; +import type { PublicAlbum } from '@/types/media'; + +const { useBreakpoint } = Grid; + +interface AlbumPhoto { + id: number; + title: string | null; + width: number | null; + height: number | null; + format: string | null; + thumbnailUrl: string; + imageUrl: string; +} + +interface ExpandedAlbumCardProps { + album: PublicAlbum; +} + +export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) { + const { token } = theme.useToken(); + const screens = useBreakpoint(); + const isMobile = !screens.md; + const { collapseVideo } = useExpandedVideo(); + + const containerRef = useRef(null); + const [hasUpvoted, setHasUpvoted] = useState(false); + const [upvoteCount, setUpvoteCount] = useState(album.upvoteCount); + const [upvoting, setUpvoting] = useState(false); + const [isExpanding, setIsExpanding] = useState(true); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + const [currentIndex, setCurrentIndex] = useState(0); + const [imageLoading, setImageLoading] = useState(true); + + const pad = isMobile ? 8 : 12; + + // Fetch album photos + useEffect(() => { + const fetchPhotos = async () => { + try { + const { data } = await mediaPublicApi.get(`/public/albums/${album.id}`); + setPhotos(data.photos || []); + } catch { + // Silent fail + } finally { + setLoading(false); + } + }; + fetchPhotos(); + }, [album.id]); + + // Keyboard navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') collapseVideo(); + if (e.key === 'ArrowLeft') setCurrentIndex(prev => Math.max(0, prev - 1)); + if (e.key === 'ArrowRight') setCurrentIndex(prev => Math.min(photos.length - 1, prev + 1)); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [collapseVideo, photos.length]); + + // Expand animation + useEffect(() => { + const timer = requestAnimationFrame(() => { + requestAnimationFrame(() => setIsExpanding(false)); + }); + return () => cancelAnimationFrame(timer); + }, []); + + // Scroll into view + useEffect(() => { + const timer = setTimeout(() => { + containerRef.current?.scrollIntoView({ + behavior: isMobile ? 'auto' : 'smooth', + block: 'nearest', + }); + }, 350); + return () => clearTimeout(timer); + }, [isMobile]); + + const handleUpvote = async () => { + if (upvoting || hasUpvoted) return; + try { + setUpvoting(true); + // Albums don't have a direct upvote — upvote the current photo instead + if (photos[currentIndex]) { + await mediaPublicApi.post(`/photos/${photos[currentIndex].id}/upvote`); + } + setHasUpvoted(true); + setUpvoteCount(prev => prev + 1); + } catch (error: any) { + if (error.response?.status === 401) { + message.info('Please log in to upvote'); + } + } finally { + setUpvoting(false); + } + }; + + const formatCount = (count: number) => { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return count.toString(); + }; + + const currentPhoto = photos[currentIndex]; + + return ( +
+ {/* Image carousel */} +
+ {loading ? ( + + ) : currentPhoto ? ( + <> + {imageLoading && ( +
+ +
+ )} + {currentPhoto.title setImageLoading(false)} + style={{ + maxWidth: '100%', + maxHeight: isMobile ? 'calc(100vh - 160px)' : 'calc(100vh - 120px)', + objectFit: 'contain', + }} + /> + + {/* Left arrow */} + {currentIndex > 0 && ( +
+ + {/* Thumbnail strip */} + {photos.length > 1 && ( +
+ {photos.map((p, idx) => ( +
{ + setImageLoading(true); + setCurrentIndex(idx); + }} + style={{ + width: 48, + height: 36, + flexShrink: 0, + borderRadius: 4, + overflow: 'hidden', + cursor: 'pointer', + border: idx === currentIndex + ? `2px solid ${token.colorPrimary}` + : '2px solid transparent', + opacity: idx === currentIndex ? 1 : 0.6, + transition: 'all 0.2s ease', + }} + > + +
+ ))} +
+ )} + + {/* Bottom info bar */} +
+ +
+
+ ); +} diff --git a/admin/src/components/media/ExpandedPhotoCard.tsx b/admin/src/components/media/ExpandedPhotoCard.tsx new file mode 100644 index 00000000..a5381b17 --- /dev/null +++ b/admin/src/components/media/ExpandedPhotoCard.tsx @@ -0,0 +1,238 @@ +import { useRef, useState, useEffect } from 'react'; +import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd'; +import { + CloseOutlined, + LikeOutlined, + LikeFilled, + EyeOutlined, + CommentOutlined, + ZoomInOutlined, + ZoomOutOutlined, + CameraOutlined, +} from '@ant-design/icons'; +import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; +import { mediaPublicApi } from '@/lib/media-public-api'; +import type { PublicPhoto } from '@/types/media'; + +const { useBreakpoint } = Grid; + +interface ExpandedPhotoCardProps { + photo: PublicPhoto; +} + +export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) { + const { token } = theme.useToken(); + const screens = useBreakpoint(); + const isMobile = !screens.md; + const { collapseVideo } = useExpandedVideo(); + + const containerRef = useRef(null); + const [hasUpvoted, setHasUpvoted] = useState(false); + const [upvoteCount, setUpvoteCount] = useState(photo.upvoteCount); + const [upvoting, setUpvoting] = useState(false); + const [isExpanding, setIsExpanding] = useState(true); + const [zoomed, setZoomed] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + + const pad = isMobile ? 8 : 12; + const title = photo.title || 'Untitled Photo'; + + // Use large image URL + const imageUrl = photo.imageUrl || photo.thumbnailUrl; + + // Handle keyboard + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') collapseVideo(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [collapseVideo]); + + // Expand animation + useEffect(() => { + const timer = requestAnimationFrame(() => { + requestAnimationFrame(() => setIsExpanding(false)); + }); + return () => cancelAnimationFrame(timer); + }, []); + + // Scroll into view + useEffect(() => { + const timer = setTimeout(() => { + containerRef.current?.scrollIntoView({ + behavior: isMobile ? 'auto' : 'smooth', + block: 'nearest', + }); + }, 350); + return () => clearTimeout(timer); + }, [isMobile]); + + // Track view + useEffect(() => { + mediaPublicApi.post('/photos/' + photo.id + '/view').catch(() => {}); + }, [photo.id]); + + const handleUpvote = async () => { + if (upvoting || hasUpvoted) return; + try { + setUpvoting(true); + await mediaPublicApi.post(`/photos/${photo.id}/upvote`); + setHasUpvoted(true); + setUpvoteCount(prev => prev + 1); + } catch (error: any) { + if (error.response?.status === 401) { + message.info('Please log in to upvote'); + } + } finally { + setUpvoting(false); + } + }; + + const formatCount = (count: number) => { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return count.toString(); + }; + + return ( +
+ {/* Image section */} +
setZoomed(!zoomed)} + > + {imageLoading && ( +
+ +
+ )} + {title} setImageLoading(false)} + style={{ + maxWidth: zoomed ? 'none' : '100%', + maxHeight: zoomed ? 'none' : isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)', + width: zoomed ? 'auto' : undefined, + objectFit: 'contain', + transition: 'transform 0.3s ease', + transform: zoomed ? 'scale(1.5)' : 'scale(1)', + }} + /> + + {/* Zoom indicator */} +
+ {zoomed ? : } + {zoomed ? 'Click to zoom out' : 'Click to zoom in'} +
+
+ + {/* Bottom info bar */} +
+ {/* Close button */} + +
+
+ ); +} diff --git a/admin/src/components/media/MediaBottomNav.tsx b/admin/src/components/media/MediaBottomNav.tsx index 72b298b2..36023282 100644 --- a/admin/src/components/media/MediaBottomNav.tsx +++ b/admin/src/components/media/MediaBottomNav.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useLocation, useNavigate } from 'react-router-dom'; -import { Input, Select, theme, Grid } from 'antd'; -import { SearchOutlined } from '@ant-design/icons'; +import { Input, Select, Button, theme, Grid } from 'antd'; +import { SearchOutlined, SendOutlined } from '@ant-design/icons'; +import { useSettingsStore } from '@/stores/settings.store'; const { useBreakpoint } = Grid; @@ -12,6 +13,7 @@ export default function MediaBottomNav() { const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const { settings } = useSettingsStore(); // Initialize from URL params const [searchInput, setSearchInput] = useState(searchParams.get('search') || ''); @@ -75,6 +77,17 @@ export default function MediaBottomNav() { zIndex: 1000, }} > + {isMobile && settings?.enableInfluence !== false && ( + + )} } diff --git a/admin/src/components/media/MediaSidebar.tsx b/admin/src/components/media/MediaSidebar.tsx index 703a5060..c7a85439 100644 --- a/admin/src/components/media/MediaSidebar.tsx +++ b/admin/src/components/media/MediaSidebar.tsx @@ -5,6 +5,7 @@ import { HomeOutlined, ThunderboltOutlined, VideoCameraOutlined, + PictureOutlined, StarOutlined, PlayCircleOutlined, TeamOutlined, @@ -125,7 +126,8 @@ export default function MediaSidebar() { { key: 'all', label: 'All', icon: , path: '/gallery' }, { key: 'shorts', label: 'Shorts', icon: , path: '/gallery/shorts' }, { key: 'videos', label: 'Videos', icon: , path: '/gallery/videos' }, -{ key: 'curated', label: 'Curated', icon: , path: '/gallery/curated' }, + { key: 'photos', label: 'Photos', icon: , path: '/gallery/photos' }, + { key: 'curated', label: 'Curated', icon: , path: '/gallery/curated' }, { key: 'playback', label: 'Playback', icon: , path: '/gallery/playback' }, ]; @@ -204,7 +206,7 @@ export default function MediaSidebar() { color: 'rgba(255,255,255,0.45)', }} > - Video Platform + Media Platform )} diff --git a/admin/src/components/media/PhotoCard.tsx b/admin/src/components/media/PhotoCard.tsx new file mode 100644 index 00000000..48aaac20 --- /dev/null +++ b/admin/src/components/media/PhotoCard.tsx @@ -0,0 +1,247 @@ +import { Card, Checkbox, Tag, Tooltip } from 'antd'; +import { + EditOutlined, + DeleteOutlined, + EyeOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + FolderOutlined, + PictureOutlined, +} from '@ant-design/icons'; +import { getAuthCallbacks } from '@/lib/api'; +import type { Photo } from '@/types/media'; + +/** Append JWT access token as query param for src URLs */ +function getAuthenticatedUrl(url: string): string { + const { getTokens } = getAuthCallbacks(); + const { accessToken } = getTokens(); + if (!accessToken) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}token=${accessToken}`; +} + +interface PhotoCardProps { + photo: Photo; + selected?: boolean; + onSelect?: (id: number) => void; + onClick?: (photo: Photo) => void; + onEdit?: (photo: Photo) => void; + onPreview?: (photo: Photo) => void; + onDelete?: (photo: Photo) => void; + onTogglePublish?: (photo: Photo) => void; +} + +function formatFileSize(sizeStr: string | null): string { + if (!sizeStr) return ''; + const bytes = parseInt(sizeStr); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function PhotoCard({ + photo, + selected, + onSelect, + onClick, + onEdit, + onPreview, + onDelete, + onTogglePublish, +}: PhotoCardProps) { + const thumbnailUrl = photo.thumbnailUrl; + + const hoverActions = ( +
+ {onEdit && ( + + { e.stopPropagation(); onEdit(photo); }} + /> + + )} + {onPreview && ( + + { e.stopPropagation(); onPreview(photo); }} + /> + + )} + {onDelete && ( + + { e.stopPropagation(); onDelete(photo); }} + /> + + )} +
+ ); + + return ( + onClick?.(photo)} + cover={ +
+ {thumbnailUrl ? ( + {photo.title + ) : ( +
+ +
+ )} + + {/* Selection checkbox */} + {onSelect && ( + { e.stopPropagation(); onSelect(photo.id); }} + onClick={(e) => e.stopPropagation()} + style={{ position: 'absolute', top: 6, left: 6, zIndex: 2 }} + /> + )} + + {/* Publish toggle pill */} + {onTogglePublish && ( +
{ + e.stopPropagation(); + onTogglePublish(photo); + }} + style={{ + position: 'absolute', + top: 8, + right: 8, + cursor: 'pointer', + background: photo.isPublished + ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' + : 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)', + color: '#fff', + padding: '4px 10px', + borderRadius: 20, + fontSize: 11, + fontWeight: 600, + display: 'flex', + alignItems: 'center', + gap: 5, + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + transition: 'all 0.2s ease', + zIndex: 11, + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.05)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)'; + }} + title={photo.isPublished ? 'Click to unpublish' : 'Click to publish'} + role="button" + aria-label={photo.isPublished ? 'Unpublish photo' : 'Publish photo'} + > + {photo.isPublished ? ( + <> + + Published + + ) : ( + <> + + Draft + + )} +
+ )} + + {/* Album badge */} + {photo.album && ( + + {photo.album.title} + + )} + + {/* Format badge */} + {photo.format && ( + + {photo.format.toUpperCase()} + + )} + + {hoverActions} +
+ } + > + + {photo.title || photo.originalFilename || photo.filename} + + } + description={ +
+ {photo.width && photo.height ? `${photo.width}×${photo.height}` : ''} + {photo.fileSize ? ` · ${formatFileSize(photo.fileSize)}` : ''} +
+ } + /> + +
+ ); +} diff --git a/admin/src/components/media/PhotoViewerModal.tsx b/admin/src/components/media/PhotoViewerModal.tsx new file mode 100644 index 00000000..2d4070f4 --- /dev/null +++ b/admin/src/components/media/PhotoViewerModal.tsx @@ -0,0 +1,103 @@ +import { Modal, Descriptions, Tag } from 'antd'; +import { CameraOutlined } from '@ant-design/icons'; +import { getAuthCallbacks } from '@/lib/api'; +import type { Photo } from '@/types/media'; + +/** Append JWT access token as query param for src URLs */ +function getAuthenticatedUrl(url: string): string { + const { getTokens } = getAuthCallbacks(); + const { accessToken } = getTokens(); + if (!accessToken) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}token=${accessToken}`; +} + +interface PhotoViewerModalProps { + photo: Photo | null; + open: boolean; + onClose: () => void; +} + +export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerModalProps) { + if (!photo) return null; + + const adminImageUrl = `/media/photos/${photo.id}/image?size=large`; + + return ( + +
+ {/* Image */} +
+ {photo.title +
+ + {/* Info panel */} +
+

+ {photo.title || photo.originalFilename || photo.filename} +

+ +
+ {photo.format && {photo.format.toUpperCase()}} + {photo.width && photo.height && {photo.width}×{photo.height}} + {photo.orientation && {photo.orientation === 'H' ? 'Landscape' : photo.orientation === 'V' ? 'Portrait' : 'Square'}} + {photo.isPublished && Published} +
+ + {(photo.cameraMake || photo.cameraModel) && ( + + {(photo.cameraMake || photo.cameraModel) && ( + Camera}> + {[photo.cameraMake, photo.cameraModel].filter(Boolean).join(' ')} + + )} + {photo.focalLength && ( + {photo.focalLength} + )} + {photo.aperture && ( + {photo.aperture} + )} + {photo.shutterSpeed && ( + {photo.shutterSpeed} + )} + {photo.iso && ( + {photo.iso} + )} + {photo.takenAt && ( + {new Date(photo.takenAt).toLocaleString()} + )} + + )} + + {photo.description && ( +

{photo.description}

+ )} +
+
+
+ ); +} diff --git a/admin/src/components/media/PublicAlbumCard.tsx b/admin/src/components/media/PublicAlbumCard.tsx new file mode 100644 index 00000000..a1f70dc0 --- /dev/null +++ b/admin/src/components/media/PublicAlbumCard.tsx @@ -0,0 +1,229 @@ +import { useState } from 'react'; +import { Card, Tag, Space, Typography, theme } from 'antd'; +import { PictureOutlined, LikeOutlined, EyeOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; +import { hexToRgba } from '@/utils/color'; +import type { PublicAlbum } from '@/types/media'; + +interface PublicAlbumCardProps { + album: PublicAlbum; +} + +export default function PublicAlbumCard({ album }: PublicAlbumCardProps) { + const { token } = theme.useToken(); + const { expandMedia } = useExpandedVideo(); + const [thumbnailError, setThumbnailError] = useState(false); + + const formatCount = (count: number | undefined | null) => { + if (!count && count !== 0) return '0'; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return count.toString(); + }; + + const handleCardClick = () => { + expandMedia(album.id, 'album', album); + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + const card = e.currentTarget; + card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`; + card.style.transform = 'translateY(-2px)'; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + const card = e.currentTarget; + card.style.boxShadow = 'none'; + card.style.transform = 'translateY(0)'; + }; + + return ( + + {/* Cover thumbnail */} + {album.coverThumbnailUrl && !thumbnailError ? ( + {album.title} setThumbnailError(true)} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + ) : ( +
+ +
+ )} + + {/* Stacked photos effect overlay */} +
+ + {/* Expand overlay on hover */} +
{ e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }} + > +
{ e.currentTarget.style.transform = 'scale(1.1)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} + > + +
+
+ + {/* Photo count badge */} +
+ + {album.photoCount} photos +
+ + {/* Category badge */} + {album.category && ( + + {album.category} + + )} + + {/* Album indicator */} + + Album + +
+ } + > + + + {album.title} + + + + + + {formatCount(album.upvoteCount)} + + + + {formatCount(album.viewCount)} + + + + {album.photoCount} + + + +
+ ); +} diff --git a/admin/src/components/media/PublicPhotoCard.tsx b/admin/src/components/media/PublicPhotoCard.tsx new file mode 100644 index 00000000..aaf1c4b1 --- /dev/null +++ b/admin/src/components/media/PublicPhotoCard.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { Card, Tag, Space, Typography, theme } from 'antd'; +import { CameraOutlined, LikeOutlined, EyeOutlined, CommentOutlined, PictureOutlined } from '@ant-design/icons'; +import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; +import { hexToRgba } from '@/utils/color'; +import type { PublicPhoto } from '@/types/media'; + +interface PublicPhotoCardProps { + photo: PublicPhoto; +} + +export default function PublicPhotoCard({ photo }: PublicPhotoCardProps) { + const { token } = theme.useToken(); + const { expandMedia } = useExpandedVideo(); + const [thumbnailError, setThumbnailError] = useState(false); + + const formatCount = (count: number | undefined | null) => { + if (!count && count !== 0) return '0'; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return count.toString(); + }; + + const title = photo.title || 'Untitled Photo'; + + const handleCardClick = () => { + expandMedia(photo.id, 'photo', photo); + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + const card = e.currentTarget; + card.style.boxShadow = `0 0 0 2px ${token.colorPrimary}`; + card.style.transform = 'translateY(-2px)'; + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + const card = e.currentTarget; + card.style.boxShadow = 'none'; + card.style.transform = 'translateY(0)'; + }; + + // Choose aspect ratio based on orientation + const aspectPadding = photo.orientation === 'V' ? '133.33%' : photo.orientation === 'S' ? '100%' : '75%'; // 3:4, 1:1, or 4:3 + + return ( + + {/* Thumbnail */} + {photo.thumbnailUrl && !thumbnailError ? ( + {title} setThumbnailError(true)} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + ) : ( +
+ +
+ )} + + {/* Camera icon overlay on hover */} +
{ e.currentTarget.style.opacity = '1'; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }} + > +
{ e.currentTarget.style.transform = 'scale(1.1)'; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} + > + +
+
+ + {/* Format badge */} + {photo.format && ( + + {photo.format} + + )} + + {/* Category badge */} + {photo.category && ( + + {photo.category} + + )} + + {/* Dimensions badge */} + {photo.width && photo.height && ( +
+ {photo.width}×{photo.height} +
+ )} + + } + > + + + {title} + + + + + + {formatCount(photo.upvoteCount)} + + + + {formatCount(photo.viewCount)} + + + + {formatCount(photo.commentCount)} + + + +
+ ); +} diff --git a/admin/src/components/media/UploadPhotoDrawer.tsx b/admin/src/components/media/UploadPhotoDrawer.tsx new file mode 100644 index 00000000..fe798947 --- /dev/null +++ b/admin/src/components/media/UploadPhotoDrawer.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, Tag } from 'antd'; +import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { mediaApi } from '@/lib/media-api'; +import type { PhotoAlbum } from '@/types/media'; + +const { Dragger } = Upload; + +interface UploadPhotoDrawerProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + albumId?: number; // Pre-select album +} + +interface UploadResult { + filename: string; + success: boolean; + error?: string; +} + +const ACCEPTED_TYPES = '.jpg,.jpeg,.png,.webp,.avif,.gif,.tiff,.tif,.heic,.heif'; + +export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }: UploadPhotoDrawerProps) { + const [form] = Form.useForm(); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [results, setResults] = useState([]); + const [albums, setAlbums] = useState([]); + const [fileList, setFileList] = useState([]); + + useEffect(() => { + if (open) { + fetchAlbums(); + setResults([]); + setProgress(0); + setFileList([]); + form.resetFields(); + if (albumId) form.setFieldsValue({ albumId }); + } + }, [open, albumId]); + + const fetchAlbums = async () => { + try { + const { data } = await mediaApi.get('/albums', { params: { limit: 200 } }); + setAlbums(data.albums || []); + } catch { + // Ignore + } + }; + + const handleUpload = async () => { + if (fileList.length === 0) { + message.warning('Select at least one photo'); + return; + } + + const values = form.getFieldsValue(); + setUploading(true); + setResults([]); + + const uploadResults: UploadResult[] = []; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i].originFileObj || fileList[i]; + const formData = new FormData(); + formData.append('file', file); + if (values.title && fileList.length === 1) formData.append('title', values.title); + if (values.producer) formData.append('producer', values.producer); + if (values.creator) formData.append('creator', values.creator); + if (values.albumId) formData.append('albumId', String(values.albumId)); + + try { + await mediaApi.post('/photos/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + uploadResults.push({ filename: file.name, success: true }); + } catch (error: any) { + uploadResults.push({ + filename: file.name, + success: false, + error: error.response?.data?.message || 'Upload failed', + }); + } + + setProgress(Math.round(((i + 1) / fileList.length) * 100)); + setResults([...uploadResults]); + } + + setUploading(false); + const successCount = uploadResults.filter(r => r.success).length; + if (successCount > 0) { + message.success(`Uploaded ${successCount} photo${successCount > 1 ? 's' : ''}`); + onSuccess(); + } + if (successCount < fileList.length) { + message.error(`${fileList.length - successCount} upload(s) failed`); + } + }; + + return ( + + + + + } + > +
+ false} + fileList={fileList} + onChange={({ fileList: fl }) => setFileList(fl)} + style={{ marginBottom: 16 }} + > +

+ +

+

Click or drag photos here

+

+ JPG, PNG, WebP, AVIF, GIF, TIFF, HEIC +

+
+ + {fileList.length === 1 && ( + + + + )} + + + + + + + + + + + } value={search} onChange={(e) => setSearch(e.target.value)} allowClear style={{ width: 200 }} /> - ({ value: p, label: p }))} - value={selectedProducers} - onChange={setSelectedProducers} - style={{ width: 140 }} - maxTagCount={1} - /> - + )} + + {/* Producer filter (Videos + Photos) */} + {mediaTab !== 'Albums' && ( + setShortsFilter(v === undefined ? undefined : v === 'true')} + allowClear + style={{ width: 110 }} + /> + )} + + {/* Photo-specific: Format filter */} + {mediaTab === 'Photos' && photoFormats.length > 0 && ( + } placeholder="Your email" autoFocus /> + + + + } placeholder="Your name (optional)" /> + + + + } placeholder="Phone (optional)" /> + + + + + +
+ + + You'll get temporary 24-hour access to the canvassing app. + +
+ +
+ + + + + ); +} diff --git a/admin/src/pages/volunteer/VolunteerMapPage.tsx b/admin/src/pages/volunteer/VolunteerMapPage.tsx index 31c70f50..39b2f29c 100644 --- a/admin/src/pages/volunteer/VolunteerMapPage.tsx +++ b/admin/src/pages/volunteer/VolunteerMapPage.tsx @@ -547,6 +547,9 @@ export default function VolunteerMapPage() { sessionStartedAt={session?.startedAt} onEndSession={handleEndSession} endingSession={endingSession} + activeCutId={activeCutId ?? undefined} + activeShiftId={session?.shiftId ?? undefined} + isAdmin={isAdmin} /> {/* Bottom sheet — visit recording */} diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index b24c3d2f..93120d8b 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -17,7 +17,7 @@ export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL'; -export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD' | 'SELF_REGISTRATION'; +export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD' | 'SELF_REGISTRATION' | 'QUICK_JOIN_INVITE'; export interface User { id: string; @@ -2012,3 +2012,87 @@ export interface ActivityTrendsData { dateTo: string; series: Array<{ date: string; emails: number; responses: number }>; } + +// --- Dashboard Top Videos --- + +export interface DashboardTopVideo { + id: number; + title: string | null; + filename: string; + viewCount: number; + commentCount: number; + upvoteCount: number; + durationSeconds: number | null; + isPublished: boolean; +} + +export interface DashboardTopVideosResult { + enabled: boolean; + videos: DashboardTopVideo[]; +} + +// --- Dashboard Recent Comments --- + +export interface DashboardRecentComment { + id: number; + content: string; + videoId: number; + videoTitle: string | null; + videoFilename: string; + authorName: string | null; + safetyStatus: string | null; + createdAt: string; +} + +export interface DashboardRecentCommentsResult { + enabled: boolean; + comments: DashboardRecentComment[]; + pendingCount: number; +} + +// --- Dashboard Docs Analytics --- + +export interface DashboardDocsAnalytics { + totalViews: number; + uniqueSessions: number; + topPages: Array<{ path: string; views: number }>; + viewsByDay: Array<{ date: string; views: number }>; + topReferrers?: Array<{ referrer: string; views: number }>; +} + +// --- Dashboard Upcoming Shifts --- + +export interface DashboardUpcomingShift { + id: string; + title: string; + date: string; + startTime: string; + endTime: string; + location: string | null; + maxVolunteers: number; + currentVolunteers: number; + status: string; + cutName: string | null; +} + +export interface DashboardUpcomingShiftsResult { + shifts: DashboardUpcomingShift[]; + total: number; +} + +// --- Dashboard Recent Signups --- + +export interface DashboardRecentSignup { + id: string; + userName: string | null; + userEmail: string; + shiftTitle: string | null; + shiftDate: string | null; + signupDate: string; + signupSource: string; +} + +export interface DashboardRecentSignupsResult { + signups: DashboardRecentSignup[]; + total: number; +} diff --git a/admin/src/types/canvass.ts b/admin/src/types/canvass.ts index 5b6eb855..4dc7b659 100644 --- a/admin/src/types/canvass.ts +++ b/admin/src/types/canvass.ts @@ -205,3 +205,15 @@ export interface VolunteerSummary { sessions: number; lastActive: string | null; } + +// --- Outcome Trends --- + +export type CanvassOutcomeTrendPoint = { date: string } & Partial>; + +export interface CanvassOutcomeTrendsData { + granularity: 'day' | 'week'; + dateFrom: string; + dateTo: string; + series: CanvassOutcomeTrendPoint[]; + totals: Partial>; +} diff --git a/admin/src/types/media.ts b/admin/src/types/media.ts index b497880d..e2b942be 100644 --- a/admin/src/types/media.ts +++ b/admin/src/types/media.ts @@ -219,3 +219,138 @@ export interface UpdatePlaylistBody { export interface ReorderPlaylistVideosBody { items: Array<{ mediaId: number; position: number }>; } + +// ============================================================================ +// PHOTO GALLERY +// ============================================================================ + +export interface Photo { + id: number; + path: string; + filename: string; + originalFilename: string | null; + title: string | null; + description: string | null; + producer: string | null; + creator: string | null; + tags: string[] | null; + width: number | null; + height: number | null; + orientation: 'H' | 'V' | 'S' | null; + fileSize: string | null; // BigInt serialized as string + format: string | null; + colorSpace: string | null; + hasAlpha: boolean | null; + dpi: number | null; + cameraMake: string | null; + cameraModel: string | null; + focalLength: string | null; + aperture: string | null; + shutterSpeed: string | null; + iso: number | null; + takenAt: string | null; + gpsLatitude: number | null; + gpsLongitude: number | null; + thumbnailPath: string | null; + thumbnailUrl: string | null; + isPublished: boolean; + publishedAt: string | null; + category: string | null; + accessLevel: string; + position: number | null; + isLocked: boolean; + viewCount: number; + upvoteCount: number; + commentCount: number; + albumId: number | null; + albumPosition: number | null; + uploaderId: string | null; + createdAt: string; + album?: { id: number; title: string } | null; + uploader?: { id: string; name: string | null; email: string } | null; +} + +export interface PhotoAlbum { + id: number; + title: string; + description: string | null; + coverPhotoId: number | null; + isPublished: boolean; + publishedAt: string | null; + category: string | null; + accessLevel: string; + position: number | null; + isLocked: boolean; + viewCount: number; + upvoteCount: number; + photoCount: number; + creatorId: string | null; + createdAt: string; + updatedAt: string; + coverPhoto?: { id: number; thumbnailPath: string | null } | null; + coverThumbnailUrl?: string | null; + creator?: { id: string; name: string | null; email: string } | null; + photos?: PhotoAlbumItem[]; + _count?: { photos: number }; +} + +export interface PhotoAlbumItem { + id: number; + title: string | null; + originalFilename: string | null; + thumbnailPath: string | null; + thumbnailUrl: string | null; + width: number | null; + height: number | null; + orientation: string | null; + format: string | null; + fileSize: string | null; + albumPosition: number | null; + isPublished: boolean; + createdAt: string; +} + +export interface PhotosListResponse { + photos: Photo[]; + total: number; + limit: number; + offset: number; +} + +export interface PublicPhoto { + id: number; + title: string | null; + width: number | null; + height: number | null; + orientation: string | null; + format: string | null; + producer: string | null; + category: string | null; + publishedAt: string | null; + viewCount: number; + upvoteCount: number; + commentCount: number; + thumbnailUrl: string; + imageUrl: string; + albumId: number | null; +} + +export interface PublicAlbum { + id: number; + title: string; + description: string | null; + category: string | null; + photoCount: number; + viewCount: number; + upvoteCount: number; + publishedAt: string | null; + coverThumbnailUrl: string | null; + coverPhoto?: { id: number; width: number | null; height: number | null } | null; +} + +export type GalleryItemType = 'video' | 'photo' | 'album'; + +export interface GalleryItem { + type: GalleryItemType; + data: any; +} diff --git a/admin/src/utils/galleryAdMerge.ts b/admin/src/utils/galleryAdMerge.ts index 95f05ed1..f9347cb6 100644 --- a/admin/src/utils/galleryAdMerge.ts +++ b/admin/src/utils/galleryAdMerge.ts @@ -1,7 +1,9 @@ import type { GalleryAd } from '@/types/gallery-ads'; -export type GridItem = +export type GridItem = | { type: 'video'; data: T } + | { type: 'photo'; data: any } + | { type: 'album'; data: any } | { type: 'ad'; data: GalleryAd }; /** @@ -68,3 +70,50 @@ export function mergeAdsIntoGrid( return result; } + +/** + * Merge ads into a unified media grid (videos + photos + albums). + */ +export function mergeAdsIntoMediaGrid( + items: Array<{ type: 'video' | 'photo' | 'album'; data: any }>, + ads: GalleryAd[] +): GridItem[] { + if (ads.length === 0) { + return items; + } + + const sortedAds = [...ads].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + const slotAssignments = new Map(); + const usedSlots = new Set(); + + for (const ad of sortedAds) { + const freq = ad.frequency; + for (let i = freq - 1; i < items.length; i += freq) { + let targetSlot = i; + while (usedSlots.has(targetSlot) && targetSlot < items.length + ads.length) { + targetSlot++; + } + if (!usedSlots.has(targetSlot)) { + slotAssignments.set(targetSlot, ad); + usedSlots.add(targetSlot); + break; + } + } + } + + const result: GridItem[] = []; + let itemIdx = 0; + + for (let pos = 0; itemIdx < items.length || slotAssignments.has(pos); pos++) { + const adForSlot = slotAssignments.get(pos); + if (adForSlot) { + result.push({ type: 'ad', data: adForSlot }); + } + if (itemIdx < items.length) { + result.push(items[itemIdx]!); + itemIdx++; + } + } + + return result; +} diff --git a/api/Dockerfile.media b/api/Dockerfile.media index 67af03eb..5e230d3c 100644 --- a/api/Dockerfile.media +++ b/api/Dockerfile.media @@ -4,12 +4,14 @@ FROM node:20-alpine AS base WORKDIR /app -# Install ffmpeg for video metadata extraction and yt-dlp for video fetching -RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp +# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching +RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp # Install dependencies +# Two-step: skip scripts to avoid sharp source build, then install prebuilt musl binary COPY package*.json ./ -RUN npm ci --omit=dev +RUN npm ci --omit=dev --ignore-scripts && \ + npm install --no-save @img/sharp-linuxmusl-x64 # Copy Prisma schema and generate client (needed for auth middleware) COPY prisma ./prisma @@ -35,8 +37,8 @@ RUN npm run build FROM node:20-alpine AS production WORKDIR /app -# Install ffmpeg for video metadata extraction and yt-dlp for video fetching -RUN apk add --no-cache ffmpeg python3 py3-pip && pip3 install --break-system-packages yt-dlp +# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching +RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp # Copy built files and node_modules COPY --from=build /app/dist ./dist diff --git a/api/package-lock.json b/api/package-lock.json index 3617f2d2..fc2dfb6c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -21,6 +21,7 @@ "csv-stringify": "^6.6.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.1", + "exif-reader": "^2.0.3", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "fastify": "^5.7.4", @@ -29,12 +30,14 @@ "jsonwebtoken": "^9.0.2", "mime-types": "^3.0.2", "multer": "^2.0.2", + "node-addon-api": "^8.5.0", "nodemailer": "^6.9.16", "pg": "^8.18.0", "proj4": "^2.20.2", "prom-client": "^15.1.3", "qrcode": "^1.5.4", "rate-limit-redis": "^4.2.0", + "sharp": "^0.34.5", "stripe": "^20.3.1", "winston": "^3.17.0", "yaml": "^2.8.2", @@ -81,6 +84,15 @@ "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "dev": true }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1165,6 +1177,446 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", @@ -2134,7 +2586,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "optional": true, "engines": { "node": ">=8" } @@ -2930,6 +3381,11 @@ "node": ">= 0.6" } }, + "node_modules/exif-reader": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/exif-reader/-/exif-reader-2.0.3.tgz", + "integrity": "sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==" + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3866,6 +4322,14 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -4636,6 +5100,49 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/api/package.json b/api/package.json index c5744358..5b62706f 100644 --- a/api/package.json +++ b/api/package.json @@ -29,6 +29,7 @@ "csv-stringify": "^6.6.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.1", + "exif-reader": "^2.0.3", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "fastify": "^5.7.4", @@ -37,12 +38,14 @@ "jsonwebtoken": "^9.0.2", "mime-types": "^3.0.2", "multer": "^2.0.2", + "node-addon-api": "^8.5.0", "nodemailer": "^6.9.16", "pg": "^8.18.0", "proj4": "^2.20.2", "prom-client": "^15.1.3", "qrcode": "^1.5.4", "rate-limit-redis": "^4.2.0", + "sharp": "^0.34.5", "stripe": "^20.3.1", "winston": "^3.17.0", "yaml": "^2.8.2", diff --git a/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql b/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql new file mode 100644 index 00000000..883f9e65 --- /dev/null +++ b/api/prisma/migrations/20260218300000_add_quick_join_invite_created_via/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserCreatedVia" ADD VALUE IF NOT EXISTS 'QUICK_JOIN_INVITE'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index c0e841ea..e294446b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -33,6 +33,7 @@ enum UserCreatedVia { PUBLIC_SHIFT_SIGNUP STANDARD SELF_REGISTRATION + QUICK_JOIN_INVITE } model User { @@ -134,6 +135,11 @@ model User { notifications Notification[] @relation("UserNotifications") notificationPreferences NotificationPreferences? @relation("NotificationPreferences") + // Photo gallery relations + photosUploaded Photo[] @relation("PhotoUploader") + albumsCreated PhotoAlbum[] @relation("AlbumCreator") + photoComments PhotoComment[] @relation("PhotoCommentUser") + @@map("users") } @@ -1616,6 +1622,11 @@ model Session { adClicks AdClick[] userFinishes UserFinish[] + // Photo gallery relations + photoUpvotes PhotoUpvote[] @relation("SessionPhotoUpvotes") + photoComments PhotoComment[] @relation("SessionPhotoComments") + photoReactions PhotoReaction[] @relation("SessionPhotoReactions") + @@index([userId], map: "idx_sessions_user_id") @@index([country], map: "idx_sessions_country") @@map("sessions") @@ -3473,3 +3484,188 @@ model DocsPageView { @@index([path, createdAt]) @@map("docs_page_views") } + +// ============================================================================ +// PHOTO GALLERY +// ============================================================================ + +model Photo { + id Int @id @default(autoincrement()) + path String @unique // Full path to original file + filename String // UUID filename on disk + originalFilename String? @map("original_filename") // Original upload filename + title String? + description String? @db.Text + producer String? + creator String? + tags Json? // String array + + // Image metadata (from sharp) + width Int? + height Int? + orientation String? // H / V / S (horizontal/vertical/square) + fileSize BigInt? @map("file_size") + format String? // jpeg, png, webp, avif, gif, tiff, heic + colorSpace String? @map("color_space") // srgb, display-p3, etc. + hasAlpha Boolean? @default(false) @map("has_alpha") + dpi Int? + + // EXIF data + cameraMake String? @map("camera_make") + cameraModel String? @map("camera_model") + focalLength String? @map("focal_length") + aperture String? + shutterSpeed String? @map("shutter_speed") + iso Int? + takenAt DateTime? @map("taken_at") + gpsLatitude Float? @map("gps_latitude") @db.Real + gpsLongitude Float? @map("gps_longitude") @db.Real + + // Processed variants + thumbnailPath String? @map("thumbnail_path") + mediumPath String? @map("medium_path") + largePath String? @map("large_path") + webpPath String? @map("webp_path") + + // Publishing (mirrors Video) + isPublished Boolean @default(false) @map("is_published") + publishedAt DateTime? @map("published_at") + category String? + accessLevel String @default("free") @map("access_level") + position Int? @default(0) + isLocked Boolean @default(false) @map("is_locked") + scheduledPublishAt DateTime? @map("scheduled_publish_at") + scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at") + + // Engagement counters + viewCount Int @default(0) @map("view_count") + upvoteCount Int @default(0) @map("upvote_count") + commentCount Int @default(0) @map("comment_count") + + // Album membership + albumId Int? @map("album_id") + albumPosition Int? @default(0) @map("album_position") + + // Tracking + uploaderId String? @map("uploader_id") + createdAt DateTime @default(now()) @map("created_at") + + // Relations + album PhotoAlbum? @relation("AlbumPhotos", fields: [albumId], references: [id], onDelete: SetNull) + uploader User? @relation("PhotoUploader", fields: [uploaderId], references: [id]) + upvotes PhotoUpvote[] + comments PhotoComment[] + views PhotoView[] + reactions PhotoReaction[] + coverForAlbum PhotoAlbum? @relation("AlbumCover") + + @@index([orientation], map: "idx_photos_orientation") + @@index([producer], map: "idx_photos_producer") + @@index([isPublished, isLocked], map: "idx_photos_published_locked") + @@index([category, isPublished], map: "idx_photos_category_published") + @@index([albumId, albumPosition], map: "idx_photos_album_position") + @@index([createdAt], map: "idx_photos_created_at") + @@index([uploaderId], map: "idx_photos_uploader") + @@map("photos") +} + +model PhotoAlbum { + id Int @id @default(autoincrement()) + title String + description String? @db.Text + coverPhotoId Int? @unique @map("cover_photo_id") + + // Publishing + isPublished Boolean @default(false) @map("is_published") + publishedAt DateTime? @map("published_at") + category String? + accessLevel String @default("free") @map("access_level") + position Int? @default(0) + isLocked Boolean @default(false) @map("is_locked") + + // Aggregate counters + viewCount Int @default(0) @map("view_count") + upvoteCount Int @default(0) @map("upvote_count") + photoCount Int @default(0) @map("photo_count") + + // Scheduling + scheduledPublishAt DateTime? @map("scheduled_publish_at") + scheduledUnpublishAt DateTime? @map("scheduled_unpublish_at") + + // Tracking + creatorId String? @map("creator_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + photos Photo[] @relation("AlbumPhotos") + coverPhoto Photo? @relation("AlbumCover", fields: [coverPhotoId], references: [id], onDelete: SetNull) + creator User? @relation("AlbumCreator", fields: [creatorId], references: [id]) + + @@index([isPublished], map: "idx_photo_albums_published") + @@index([creatorId], map: "idx_photo_albums_creator") + @@map("photo_albums") +} + +model PhotoUpvote { + id Int @id @default(autoincrement()) + photoId Int @map("photo_id") + sessionId String @map("session_id") + createdAt DateTime @default(now()) @map("created_at") + + photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade) + session Session @relation("SessionPhotoUpvotes", fields: [sessionId], references: [id]) + + @@unique([photoId, sessionId], map: "idx_photo_upvotes_unique") + @@index([photoId], map: "idx_photo_upvotes_photo") + @@map("photo_upvotes") +} + +model PhotoComment { + id Int @id @default(autoincrement()) + photoId Int @map("photo_id") + sessionId String @map("session_id") + userId String? @map("user_id") + content String @db.Text + createdAt DateTime @default(now()) @map("created_at") + safetyStatus String @default("approved") @map("safety_status") + isHidden Boolean @default(false) @map("is_hidden") + + photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade) + session Session @relation("SessionPhotoComments", fields: [sessionId], references: [id]) + user User? @relation("PhotoCommentUser", fields: [userId], references: [id]) + + @@index([photoId, createdAt], map: "idx_photo_comments_photo_date") + @@index([sessionId], map: "idx_photo_comments_session") + @@map("photo_comments") +} + +model PhotoView { + id Int @id @default(autoincrement()) + photoId Int @map("photo_id") + sessionId String? @map("session_id") + userId String? @map("user_id") + ipAddressHash String? @map("ip_address_hash") + viewedAt DateTime @default(now()) @map("viewed_at") + + photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade) + + @@index([photoId, viewedAt], map: "idx_photo_views_photo_date") + @@index([sessionId], map: "idx_photo_views_session") + @@map("photo_views") +} + +model PhotoReaction { + id Int @id @default(autoincrement()) + photoId Int @map("photo_id") + sessionId String @map("session_id") + reactionType String @map("reaction_type") // like, love, laugh, wow, sad, angry + createdAt DateTime @default(now()) @map("created_at") + + photo Photo @relation(fields: [photoId], references: [id], onDelete: Cascade) + session Session @relation("SessionPhotoReactions", fields: [sessionId], references: [id]) + + @@unique([photoId, sessionId, reactionType], map: "idx_photo_reactions_unique") + @@index([photoId], map: "idx_photo_reactions_photo") + @@map("photo_reactions") +} diff --git a/api/src/media-server.ts b/api/src/media-server.ts index bca70b9b..de16fd54 100644 --- a/api/src/media-server.ts +++ b/api/src/media-server.ts @@ -25,6 +25,11 @@ import { fetchRoutes } from './modules/media/routes/fetch.routes'; import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes'; import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes'; import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes'; +import { photosRoutes } from './modules/media/routes/photos.routes'; +import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes'; +import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes'; +import { photosPublicRoutes } from './modules/media/routes/photos-public.routes'; +import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes'; // Add BigInt serialization support for Prisma BigInt fields // This converts BigInt values to strings when JSON.stringify() is called @@ -141,6 +146,13 @@ const start = async () => { await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' }); await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' }); + // Photo gallery routes + await fastify.register(photosRoutes, { prefix: '/api/photos' }); + await fastify.register(photoUploadRoutes, { prefix: '/api/photos' }); + await fastify.register(photoAlbumsRoutes, { prefix: '/api/albums' }); + await fastify.register(photosPublicRoutes, { prefix: '/api' }); + await fastify.register(photoEngagementRoutes, { prefix: '/api' }); + const port = env.MEDIA_API_PORT; const host = '0.0.0.0'; diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts index 757daf07..da65c957 100644 --- a/api/src/middleware/rate-limit.ts +++ b/api/src/middleware/rate-limit.ts @@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({ }, }); +export const quickJoinRateLimit = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, + standardHeaders: true, + legacyHeaders: false, + store: new RedisStore({ + sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise, + prefix: 'rl:quick-join:', + }), + message: { + error: { + message: 'Too many join attempts, please try again later', + code: 'QUICK_JOIN_RATE_LIMIT_EXCEEDED', + }, + }, +}); + export const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, // Reduced from 20 to prevent brute force attacks diff --git a/api/src/modules/auth/auth.service.ts b/api/src/modules/auth/auth.service.ts index e170ed5f..7f9b90f3 100644 --- a/api/src/modules/auth/auth.service.ts +++ b/api/src/modules/auth/auth.service.ts @@ -18,7 +18,7 @@ interface TokenPayload { roles: UserRole[]; } -interface TokenPair { +export interface TokenPair { accessToken: string; refreshToken: string; } diff --git a/api/src/modules/dashboard/dashboard.routes.ts b/api/src/modules/dashboard/dashboard.routes.ts index 07ab0d54..b0972463 100644 --- a/api/src/modules/dashboard/dashboard.routes.ts +++ b/api/src/modules/dashboard/dashboard.routes.ts @@ -13,6 +13,10 @@ import { getConnectivity, getTodayEvents, getChatSummary, + getTopVideos, + getRecentComments, + getUpcomingShifts, + getRecentSignups, } from './dashboard.service'; const router = Router(); @@ -168,4 +172,44 @@ router.get('/chat-summary', async (_req: Request, res: Response, next: NextFunct } }); +// GET /api/dashboard/upcoming-shifts — next 5 shifts +router.get('/upcoming-shifts', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await getUpcomingShifts(); + res.json(result); + } catch (err) { + next(err); + } +}); + +// GET /api/dashboard/recent-signups — latest 8 shift signups +router.get('/recent-signups', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await getRecentSignups(); + res.json(result); + } catch (err) { + next(err); + } +}); + +// GET /api/dashboard/top-videos — top 5 videos by view count (if media enabled) +router.get('/top-videos', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await getTopVideos(); + res.json(result); + } catch (err) { + next(err); + } +}); + +// GET /api/dashboard/recent-comments — latest 8 visible comments (if media enabled) +router.get('/recent-comments', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await getRecentComments(); + res.json(result); + } catch (err) { + next(err); + } +}); + export const dashboardRouter = router; diff --git a/api/src/modules/dashboard/dashboard.service.ts b/api/src/modules/dashboard/dashboard.service.ts index f4a44bd9..543b3a83 100644 --- a/api/src/modules/dashboard/dashboard.service.ts +++ b/api/src/modules/dashboard/dashboard.service.ts @@ -10,6 +10,7 @@ import { isServiceOnline } from '../../utils/health-check'; import { listmonkClient } from '../../services/listmonk.client'; import { gancioClient } from '../../services/gancio.client'; import { rocketchatClient } from '../../services/rocketchat.client'; +import { emailService } from '../../services/email.service'; import { logger } from '../../utils/logger'; // --- Types --- @@ -418,7 +419,7 @@ export interface ConnectivityStatus { export async function getConnectivity(): Promise { const [smtp, listmonk, rocketchat, gancio] = await Promise.all([ - isServiceOnline(`${env.SMTP_HOST}`, 3000).catch(() => false), + emailService.testConnection().catch(() => false), listmonkClient.checkHealth().catch(() => false), isServiceOnline(env.ROCKETCHAT_URL || '', 3000).catch(() => false), gancioClient.isAvailable().catch(() => false), @@ -860,6 +861,243 @@ export async function getTodayEvents(): Promise { } } +// --- Upcoming Shifts --- + +export interface UpcomingShiftItem { + id: string; + title: string; + date: string; + startTime: string; + endTime: string; + location: string | null; + maxVolunteers: number; + currentVolunteers: number; + status: string; + cutName: string | null; +} + +export interface UpcomingShiftsResult { + shifts: UpcomingShiftItem[]; + total: number; +} + +export async function getUpcomingShifts(): Promise { + try { + const now = new Date(); + const [shifts, total] = await Promise.all([ + prisma.shift.findMany({ + where: { date: { gte: now }, status: { not: 'CANCELLED' } }, + orderBy: { date: 'asc' }, + take: 5, + select: { + id: true, + title: true, + date: true, + startTime: true, + endTime: true, + location: true, + maxVolunteers: true, + currentVolunteers: true, + status: true, + cut: { select: { name: true } }, + }, + }), + prisma.shift.count({ where: { date: { gte: now }, status: { not: 'CANCELLED' } } }), + ]); + + return { + shifts: shifts.map(s => ({ + id: s.id, + title: s.title, + date: s.date.toISOString(), + startTime: s.startTime, + endTime: s.endTime, + location: s.location, + maxVolunteers: s.maxVolunteers, + currentVolunteers: s.currentVolunteers, + status: s.status, + cutName: s.cut?.name || null, + })), + total, + }; + } catch (err) { + logger.debug('Failed to fetch upcoming shifts', err); + return { shifts: [], total: 0 }; + } +} + +// --- Recent Shift Signups --- + +export interface RecentSignupItem { + id: string; + userName: string | null; + userEmail: string; + shiftTitle: string | null; + shiftDate: string | null; + signupDate: string; + signupSource: string; +} + +export interface RecentSignupsResult { + signups: RecentSignupItem[]; + total: number; +} + +export async function getRecentSignups(): Promise { + try { + const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); // last 14 days + const [signups, total] = await Promise.all([ + prisma.shiftSignup.findMany({ + where: { signupDate: { gte: since }, status: 'CONFIRMED' }, + orderBy: { signupDate: 'desc' }, + take: 8, + select: { + id: true, + userName: true, + userEmail: true, + shiftTitle: true, + signupDate: true, + signupSource: true, + shift: { select: { date: true } }, + }, + }), + prisma.shiftSignup.count({ where: { signupDate: { gte: since }, status: 'CONFIRMED' } }), + ]); + + return { + signups: signups.map(s => ({ + id: s.id, + userName: s.userName, + userEmail: s.userEmail, + shiftTitle: s.shiftTitle, + shiftDate: s.shift.date.toISOString(), + signupDate: s.signupDate.toISOString(), + signupSource: s.signupSource, + })), + total, + }; + } catch (err) { + logger.debug('Failed to fetch recent signups', err); + return { signups: [], total: 0 }; + } +} + +// --- Top Videos (Media) --- + +export interface TopVideoItem { + id: number; + title: string | null; + filename: string; + viewCount: number; + commentCount: number; + upvoteCount: number; + durationSeconds: number | null; + isPublished: boolean; +} + +export interface TopVideosResult { + enabled: boolean; + videos: TopVideoItem[]; +} + +export async function getTopVideos(): Promise { + if (env.ENABLE_MEDIA_FEATURES !== 'true') { + return { enabled: false, videos: [] }; + } + + try { + const videos = await prisma.video.findMany({ + select: { + id: true, + title: true, + filename: true, + viewCount: true, + commentCount: true, + upvoteCount: true, + durationSeconds: true, + isPublished: true, + }, + orderBy: { viewCount: 'desc' }, + take: 5, + }); + + return { + enabled: true, + videos: videos.map(v => ({ + id: v.id, + title: v.title, + filename: v.filename, + viewCount: v.viewCount, + commentCount: v.commentCount, + upvoteCount: v.upvoteCount, + durationSeconds: v.durationSeconds, + isPublished: v.isPublished, + })), + }; + } catch (err) { + logger.debug('Failed to fetch top videos', err); + return { enabled: true, videos: [] }; + } +} + +// --- Recent Comments (Media) --- + +export interface RecentCommentItem { + id: number; + content: string; + videoId: number; + videoTitle: string | null; + videoFilename: string; + authorName: string | null; + safetyStatus: string | null; + createdAt: string; +} + +export interface RecentCommentsResult { + enabled: boolean; + comments: RecentCommentItem[]; + pendingCount: number; +} + +export async function getRecentComments(): Promise { + if (env.ENABLE_MEDIA_FEATURES !== 'true') { + return { enabled: false, comments: [], pendingCount: 0 }; + } + + try { + const [comments, pendingCount] = await Promise.all([ + prisma.comment.findMany({ + where: { isHidden: { not: true } }, + include: { + user: { select: { name: true, email: true } }, + media: { select: { id: true, title: true, filename: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 8, + }), + prisma.comment.count({ where: { safetyStatus: 'pending' } }), + ]); + + return { + enabled: true, + comments: comments.map(c => ({ + id: c.id, + content: c.content.slice(0, 200), + videoId: c.media.id, + videoTitle: c.media.title, + videoFilename: c.media.filename, + authorName: c.user?.name || c.user?.email || null, + safetyStatus: c.safetyStatus, + createdAt: c.createdAt.toISOString(), + })), + pendingCount, + }; + } catch (err) { + logger.debug('Failed to fetch recent comments', err); + return { enabled: true, comments: [], pendingCount: 0 }; + } +} + // --- Chat Summary from Rocket.Chat --- export interface ChatMessage { diff --git a/api/src/modules/map/canvass/canvass.routes.ts b/api/src/modules/map/canvass/canvass.routes.ts index 6d0e0c7c..050b98ab 100644 --- a/api/src/modules/map/canvass/canvass.routes.ts +++ b/api/src/modules/map/canvass/canvass.routes.ts @@ -11,6 +11,7 @@ import { adminVisitsSchema, volunteerUpdateLocationSchema, volunteerCreateLocationSchema, + outcomeTrendsQuerySchema, } from './canvass.schemas'; import { reverseGeocodeSchema, geocodeAddressSchema } from '../locations/locations.schemas'; import { locationsService } from '../locations/locations.service'; @@ -365,4 +366,18 @@ adminRouter.get( }, ); +// GET /api/map/canvass/trends +adminRouter.get( + '/trends', + validate(outcomeTrendsQuerySchema, 'query'), + async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await canvassService.getOutcomeTrends(req.query as any); + res.json(result); + } catch (err) { + next(err); + } + }, +); + export { volunteerRouter as canvassVolunteerRouter, adminRouter as canvassAdminRouter }; diff --git a/api/src/modules/map/canvass/canvass.schemas.ts b/api/src/modules/map/canvass/canvass.schemas.ts index 6c45fe86..40723383 100644 --- a/api/src/modules/map/canvass/canvass.schemas.ts +++ b/api/src/modules/map/canvass/canvass.schemas.ts @@ -101,5 +101,12 @@ export type WalkingRouteInput = z.infer; export type ListMyVisitsInput = z.infer; export type AdminActivityInput = z.infer; export type AdminVisitsInput = z.infer; +export const outcomeTrendsQuerySchema = z.object({ + granularity: z.enum(['day', 'week']).default('day'), + dateFrom: z.string().optional(), + dateTo: z.string().optional(), +}); + export type VolunteerUpdateLocationInput = z.infer; export type VolunteerCreateLocationInput = z.infer; +export type OutcomeTrendsQueryInput = z.infer; diff --git a/api/src/modules/map/canvass/canvass.service.ts b/api/src/modules/map/canvass/canvass.service.ts index 24ff346e..48cf63fb 100644 --- a/api/src/modules/map/canvass/canvass.service.ts +++ b/api/src/modules/map/canvass/canvass.service.ts @@ -21,6 +21,7 @@ import type { AdminActivityInput, AdminVisitsInput, VolunteerUpdateLocationInput, + OutcomeTrendsQueryInput, } from './canvass.schemas'; const ADDRESS_SELECT = { @@ -995,6 +996,56 @@ export const canvassService = { }; }, + async getOutcomeTrends(filters: OutcomeTrendsQueryInput) { + const { granularity } = filters; + const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(); + const dateFrom = filters.dateFrom + ? new Date(filters.dateFrom) + : new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000); + + // Ensure dateTo covers end of day + const dateToEnd = new Date(dateTo); + dateToEnd.setHours(23, 59, 59, 999); + + const rows = await prisma.$queryRaw< + { period: Date; outcome: string; count: number }[] + >` + SELECT DATE_TRUNC(${granularity}, "visitedAt") as period, + outcome::text as outcome, + COUNT(*)::int as count + FROM canvass_visits + WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd} + GROUP BY period, outcome + ORDER BY period ASC + `; + + // Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }] + const pivotMap = new Map>(); + const totals: Record = {}; + + for (const row of rows) { + const dateStr = row.period.toISOString().split('T')[0]; + if (!pivotMap.has(dateStr)) { + pivotMap.set(dateStr, {}); + } + pivotMap.get(dateStr)![row.outcome] = row.count; + totals[row.outcome] = (totals[row.outcome] || 0) + row.count; + } + + const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({ + date, + ...outcomes, + })); + + return { + granularity, + dateFrom: dateFrom.toISOString().split('T')[0], + dateTo: dateTo.toISOString().split('T')[0], + series, + totals, + }; + }, + // ─── Helpers ─────────────────────────────────────────────────────── async recalculateCutCompletion(cutId: string) { diff --git a/api/src/modules/media/middleware/auth.ts b/api/src/modules/media/middleware/auth.ts index fdfbe3fa..66cfa1eb 100644 --- a/api/src/modules/media/middleware/auth.ts +++ b/api/src/modules/media/middleware/auth.ts @@ -33,16 +33,23 @@ export async function authenticate( reply: FastifyReply ): Promise { const authHeader = request.headers.authorization; + const queryToken = (request.query as Record)?.token; - if (!authHeader?.startsWith('Bearer ')) { + // Support both Authorization header and ?token= query param (for /