+
,
- children:
,
+ children:
,
},
];
@@ -684,6 +685,7 @@ export default function SettingsPage() {
Save Settings
+
);
}
diff --git a/admin/src/pages/ShiftsPage.tsx b/admin/src/pages/ShiftsPage.tsx
index c752aa58..f1a11c41 100644
--- a/admin/src/pages/ShiftsPage.tsx
+++ b/admin/src/pages/ShiftsPage.tsx
@@ -62,6 +62,7 @@ import type {
} from '@/types/api';
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
import EditModeModal from '@/components/shifts/EditModeModal';
+import { getErrorMessage } from '@/utils/getErrorMessage';
import ShiftsCalendar from '@/components/shifts/ShiftsCalendar';
const { Text } = Typography;
@@ -1100,8 +1101,8 @@ export default function ShiftsPage() {
message.success('Video briefing created');
fetchShifts();
setEditingShift({ ...editingShift, meeting: data, meetingId: data.id });
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to create video briefing');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to create video briefing'));
}
}}
>
diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx
index cfc5e253..3429b1e3 100644
--- a/admin/src/pages/UsersPage.tsx
+++ b/admin/src/pages/UsersPage.tsx
@@ -68,6 +68,7 @@ import type {
ServicesConfig,
} from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
+import { PageTour } from '@/components/tour/PageTour';
const roleColors: Record
= {
@@ -449,6 +450,7 @@ export default function UsersPage() {
title: 'Status',
dataIndex: 'status',
key: 'status',
+ onHeaderCell: () => ({ 'data-tour-users-status': true } as Record),
render: (status: UserStatus) => (
{status.replace(/_/g, ' ')}
),
@@ -535,6 +537,7 @@ export default function UsersPage() {
type="primary"
icon={ }
onClick={() => setCreateDrawerOpen(true)}
+ data-tour-users-create
>
Create User
@@ -726,22 +729,24 @@ export default function UsersPage() {
}]}
/>
-
- columns={columns}
- dataSource={users}
- rowKey="id"
- loading={loading}
- pagination={{
- current: pagination.page,
- pageSize: pagination.limit,
- total: pagination.total,
- showSizeChanger: true,
- showTotal: (total) => `${total} users`,
- }}
- onChange={handleTableChange}
- scroll={{ x: 'max-content' }}
- locale={{ emptyText: 'No users found' }}
- />
+
+
+ columns={columns}
+ dataSource={users}
+ rowKey="id"
+ loading={loading}
+ pagination={{
+ current: pagination.page,
+ pageSize: pagination.limit,
+ total: pagination.total,
+ showSizeChanger: true,
+ showTotal: (total) => `${total} users`,
+ }}
+ onChange={handleTableChange}
+ scroll={{ x: 'max-content' }}
+ locale={{ emptyText: 'No users found' }}
+ />
+
{/* Create Drawer */}
@@ -1141,6 +1146,7 @@ export default function UsersPage() {
rows={3}
/>
+
>
);
}
diff --git a/admin/src/pages/influence/ImpactStoriesPage.tsx b/admin/src/pages/influence/ImpactStoriesPage.tsx
index aa55e6a5..38ad71f4 100644
--- a/admin/src/pages/influence/ImpactStoriesPage.tsx
+++ b/admin/src/pages/influence/ImpactStoriesPage.tsx
@@ -23,6 +23,7 @@ import {
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
+import axios from 'axios';
const { TextArea } = Input;
@@ -147,8 +148,8 @@ export default function ImpactStoriesPage() {
}
setModalOpen(false);
fetchStories(pagination.current, pagination.pageSize);
- } catch (err: any) {
- if (err?.response?.data?.error?.message) {
+ } catch (err: unknown) {
+ if (axios.isAxiosError(err) && err.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
}
diff --git a/admin/src/pages/media/AdAnalyticsDashboardPage.tsx b/admin/src/pages/media/AdAnalyticsDashboardPage.tsx
index 1e38a6b6..0182dbf2 100644
--- a/admin/src/pages/media/AdAnalyticsDashboardPage.tsx
+++ b/admin/src/pages/media/AdAnalyticsDashboardPage.tsx
@@ -10,6 +10,7 @@ import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
import FeatureGate from '@/components/FeatureGate';
+import { getErrorMessage } from '@/utils/getErrorMessage';
interface DailyData {
date: string;
@@ -85,8 +86,8 @@ export default function AdAnalyticsDashboardPage() {
params: { days },
});
setData(result);
- } catch (err: any) {
- setError(err?.response?.data?.error || 'Failed to load analytics');
+ } catch (err: unknown) {
+ setError(getErrorMessage(err, 'Failed to load analytics'));
} finally {
setLoading(false);
}
diff --git a/admin/src/pages/media/AnalyticsDashboardPage.tsx b/admin/src/pages/media/AnalyticsDashboardPage.tsx
index d9790de8..8db953d8 100644
--- a/admin/src/pages/media/AnalyticsDashboardPage.tsx
+++ b/admin/src/pages/media/AnalyticsDashboardPage.tsx
@@ -11,6 +11,7 @@ import { useOutletContext } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
import type { AnalyticsOverviewResponse, TopVideosResponse } from '@/types/media';
import type { AppOutletContext } from '@/types/api';
+import { getErrorMessage } from '@/utils/getErrorMessage';
export default function AnalyticsDashboardPage() {
const { setPageHeader } = useOutletContext();
@@ -41,9 +42,9 @@ export default function AnalyticsDashboardPage() {
setError(null);
const response = await mediaApi.get('/videos/analytics/overview');
setOverview(response.data);
- } catch (error: any) {
+ } catch (error: unknown) {
console.error('Failed to fetch analytics overview:', error);
- setError(error.response?.data?.message || 'Failed to load analytics. Please try again.');
+ setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally {
setLoading(false);
}
@@ -55,7 +56,7 @@ export default function AnalyticsDashboardPage() {
params: { metric: topMetric, limit: 10 },
});
setTopVideos(response.data);
- } catch (error: any) {
+ } catch (error: unknown) {
console.error('Failed to fetch top videos:', error);
}
};
diff --git a/admin/src/pages/media/CommentModerationPage.tsx b/admin/src/pages/media/CommentModerationPage.tsx
index d4bead1e..da737f8f 100644
--- a/admin/src/pages/media/CommentModerationPage.tsx
+++ b/admin/src/pages/media/CommentModerationPage.tsx
@@ -35,6 +35,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@@ -231,8 +232,8 @@ export default function CommentModerationPage() {
message.success('Word added to filter');
setNewWord('');
fetchWordFilters();
- } catch (err: any) {
- message.error(err.response?.data?.message || 'Failed to add word');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to add word'));
}
};
diff --git a/admin/src/pages/media/GalleryAdsPage.tsx b/admin/src/pages/media/GalleryAdsPage.tsx
index df320817..35a7f04e 100644
--- a/admin/src/pages/media/GalleryAdsPage.tsx
+++ b/admin/src/pages/media/GalleryAdsPage.tsx
@@ -48,6 +48,8 @@ import GalleryAdCard from '@/components/media/GalleryAdCard';
import PhotoPickerModal from '@/components/media/PhotoPickerModal';
import type { Photo } from '@/components/media/PhotoPickerModal';
import dayjs from 'dayjs';
+import axios from 'axios';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@@ -200,10 +202,10 @@ export default function GalleryAdsPage() {
setDrawerOpen(false);
fetchAds();
- } catch (err: any) {
- if (err?.response?.data?.error) {
+ } catch (err: unknown) {
+ if (axios.isAxiosError(err) && err.response?.data?.error) {
message.error(err.response.data.error);
- } else if (!err?.errorFields) {
+ } else if (!(err instanceof Object && 'errorFields' in err)) {
message.error('Failed to save ad');
}
} finally {
@@ -216,8 +218,8 @@ export default function GalleryAdsPage() {
await api.delete(`/gallery-ads/admin/${id}`);
message.success('Ad deleted');
fetchAds();
- } catch (err: any) {
- message.error(err?.response?.data?.error || 'Failed to delete ad');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to delete ad'));
}
};
diff --git a/admin/src/pages/media/LibraryPage.tsx b/admin/src/pages/media/LibraryPage.tsx
index 434fca3b..cbcc5f35 100644
--- a/admin/src/pages/media/LibraryPage.tsx
+++ b/admin/src/pages/media/LibraryPage.tsx
@@ -44,6 +44,8 @@ import FetchVideosDrawer from '@/components/media/FetchVideosDrawer';
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
+import { getErrorMessage } from '@/utils/getErrorMessage';
+import { PageTour } from '@/components/tour/PageTour';
type MediaTab = 'Videos' | 'Photos' | 'Albums';
@@ -150,8 +152,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get('/videos', { params });
setVideos(data.videos);
setVideoPagination((prev) => ({ ...prev, total: data.total }));
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to load videos');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to load videos'));
} finally {
setLoading(false);
}
@@ -172,8 +174,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get('/photos', { params });
setPhotos(data.photos);
setPhotoPagination((prev) => ({ ...prev, total: data.total }));
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to load photos');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to load photos'));
} finally {
setLoading(false);
}
@@ -191,8 +193,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.get('/albums', { params });
setAlbums(data.albums || []);
setAlbumPagination((prev) => ({ ...prev, total: data.total }));
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to load albums');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to load albums'));
} finally {
setLoading(false);
}
@@ -205,8 +207,8 @@ export default function LibraryPage() {
const { data } = await mediaApi.post<{ classified: number; declassified: number; totalShorts: number }>('/shorts/scan');
message.success(`Classified ${data.classified} shorts, declassified ${data.declassified}. Total shorts: ${data.totalShorts}`);
fetchVideos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to scan shorts');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to scan shorts'));
} finally {
setScanningShorts(false);
}
@@ -231,8 +233,8 @@ export default function LibraryPage() {
setDeleteModalOpen(false);
setSelectedVideoIds([]);
fetchVideos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to delete videos');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to delete videos'));
} finally {
setDeleting(false);
}
@@ -249,8 +251,8 @@ export default function LibraryPage() {
await mediaApi.delete(`/videos/${video.id}`);
message.success('Video deleted');
fetchVideos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to delete video');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to delete video'));
}
},
});
@@ -266,8 +268,8 @@ export default function LibraryPage() {
message.success(`"${video.title || video.filename}" published`);
}
fetchVideos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to toggle publish');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to toggle publish'));
}
};
@@ -293,8 +295,8 @@ export default function LibraryPage() {
message.success('Photo published');
}
fetchPhotos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to toggle publish');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to toggle publish'));
}
};
@@ -309,8 +311,8 @@ export default function LibraryPage() {
await mediaApi.delete(`/photos/${photo.id}`);
message.success('Photo deleted');
fetchPhotos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to delete photo');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to delete photo'));
}
},
});
@@ -322,8 +324,8 @@ export default function LibraryPage() {
message.success(`Published ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to publish photos');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to publish photos'));
}
};
@@ -333,8 +335,8 @@ export default function LibraryPage() {
message.success(`Deleted ${selectedPhotoIds.length} photos`);
setSelectedPhotoIds([]);
fetchPhotos();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to delete photos');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to delete photos'));
}
};
@@ -354,7 +356,7 @@ export default function LibraryPage() {
return (
{/* Media Type Toggle */}
-
+
{/* Toolbar */}
-
+
}
@@ -438,7 +440,7 @@ export default function LibraryPage() {
{/* Upload button */}
{mediaTab === 'Videos' && (
<>
-
} onClick={() => setUploadVideoOpen(true)}>Upload
+
} onClick={() => setUploadVideoOpen(true)} data-tour-media-upload>Upload
} onClick={() => setFetchDrawerOpen(true)} />
@@ -742,6 +744,7 @@ export default function LibraryPage() {
onClose={() => setSelectedAlbumId(null)}
onRefresh={fetchAlbums}
/>
+
);
}
diff --git a/admin/src/pages/media/MediaJobsPage.tsx b/admin/src/pages/media/MediaJobsPage.tsx
index e9c45be1..66df7b45 100644
--- a/admin/src/pages/media/MediaJobsPage.tsx
+++ b/admin/src/pages/media/MediaJobsPage.tsx
@@ -6,6 +6,7 @@ import dayjs from 'dayjs';
import { mediaApi } from '@/lib/media-api';
import type { Job, JobsListResponse } from '@/types/media';
import type { AppOutletContext } from '@/types/api';
+import { getErrorMessage } from '@/utils/getErrorMessage';
export default function MediaJobsPage() {
const { setPageHeader } = useOutletContext
();
@@ -30,7 +31,7 @@ export default function MediaJobsPage() {
try {
const { data } = await mediaApi.get('/jobs');
setJobs(data.jobs);
- } catch (error: any) {
+ } catch (error: unknown) {
console.error('Failed to load jobs:', error);
// Don't show error message on polling failures to avoid spam
}
@@ -41,8 +42,8 @@ export default function MediaJobsPage() {
await mediaApi.post(`/jobs/${jobId}/cancel`);
message.success('Job cancelled');
fetchJobs();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to cancel job');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to cancel job'));
}
};
@@ -54,8 +55,8 @@ export default function MediaJobsPage() {
setSelectedJob(null);
}
fetchJobs();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to delete job');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to delete job'));
}
};
@@ -65,8 +66,8 @@ export default function MediaJobsPage() {
await mediaApi.delete('/jobs/completed');
message.success('Completed jobs cleaned up');
fetchJobs();
- } catch (error: any) {
- message.error(error.response?.data?.message || 'Failed to clean up jobs');
+ } catch (error: unknown) {
+ message.error(getErrorMessage(error, 'Failed to clean up jobs'));
} finally {
setLoading(false);
}
diff --git a/admin/src/pages/media/PlaylistManagementPage.tsx b/admin/src/pages/media/PlaylistManagementPage.tsx
index 733e497c..65b11500 100644
--- a/admin/src/pages/media/PlaylistManagementPage.tsx
+++ b/admin/src/pages/media/PlaylistManagementPage.tsx
@@ -31,6 +31,7 @@ import { mediaApi } from '@/lib/media-api';
import type { AppOutletContext } from '@/types/api';
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
+import axios from 'axios';
const { Text } = Typography;
@@ -159,8 +160,8 @@ export default function PlaylistManagementPage() {
message.success('Playlist featured');
fetchAll();
fetchFeatured();
- } catch (error: any) {
- if (error.response?.status === 409) {
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 409) {
message.warning('Already featured');
} else {
message.error('Failed to feature playlist');
diff --git a/admin/src/pages/public/MediaViewerPage.tsx b/admin/src/pages/public/MediaViewerPage.tsx
index 9ffadfd2..73624d98 100644
--- a/admin/src/pages/public/MediaViewerPage.tsx
+++ b/admin/src/pages/public/MediaViewerPage.tsx
@@ -30,6 +30,7 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { Link } from 'react-router-dom';
+import axios from 'axios';
const { Title, Text } = Typography;
@@ -98,11 +99,11 @@ export default function MediaViewerPage() {
});
}
}
- } catch (error: any) {
- if (error.response?.status === 404) {
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
message.error('Video not found');
navigate('/gallery');
- } else if (error.response?.status === 401) {
+ } else if (axios.isAxiosError(error) && error.response?.status === 401) {
Modal.confirm({
title: 'Login Required',
content: 'This video is locked. Please log in to watch.',
@@ -193,8 +194,8 @@ export default function MediaViewerPage() {
}
message.success(response.data.upvoted ? 'Upvoted!' : 'Upvote removed');
- } catch (error: any) {
- if (error.response?.status === 401) {
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
message.warning('Please log in to upvote');
} else {
message.error('Failed to upvote');
diff --git a/admin/src/pages/public/MySettingsPage.tsx b/admin/src/pages/public/MySettingsPage.tsx
index f501e47f..41f898fa 100644
--- a/admin/src/pages/public/MySettingsPage.tsx
+++ b/admin/src/pages/public/MySettingsPage.tsx
@@ -7,6 +7,7 @@ import {
SaveOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -160,9 +161,8 @@ export default function MySettingsPage() {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
- } catch (err: any) {
- const msg = err?.response?.data?.message || 'Failed to change password';
- message.error(msg);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to change password'));
} finally {
setChangingPassword(false);
}
diff --git a/admin/src/pages/public/PlaylistViewerPage.tsx b/admin/src/pages/public/PlaylistViewerPage.tsx
index 682ea2d3..c14c033d 100644
--- a/admin/src/pages/public/PlaylistViewerPage.tsx
+++ b/admin/src/pages/public/PlaylistViewerPage.tsx
@@ -27,6 +27,7 @@ import PublicNavBar from '@/components/PublicNavBar';
import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api';
import type { PlaylistDetail } from '@/types/media';
+import axios from 'axios';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -81,8 +82,8 @@ export default function PlaylistViewerPage() {
} else if (data.videos?.length > 0) {
setCurrentVideoId(data.videos[0]!.mediaId);
}
- } catch (error: any) {
- if (error.response?.status === 404) {
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
message.error('Playlist not found');
navigate('/gallery/curated');
} else {
diff --git a/admin/src/pages/public/ResponseWallPage.tsx b/admin/src/pages/public/ResponseWallPage.tsx
index 6846cae6..88c73248 100644
--- a/admin/src/pages/public/ResponseWallPage.tsx
+++ b/admin/src/pages/public/ResponseWallPage.tsx
@@ -44,6 +44,7 @@ import {
} from '@/types/api';
import dayjs from 'dayjs';
import { useSettingsStore } from '@/stores/settings.store';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@@ -184,9 +185,8 @@ export default function ResponseWallPage() {
form.resetFields();
fetchResponses();
fetchStats();
- } catch (err: any) {
- const msg = err.response?.data?.error?.message || 'Failed to submit response';
- message.error(msg);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to submit response'));
} finally {
setSubmitting(false);
}
diff --git a/admin/src/pages/public/SchedulingPollPage.tsx b/admin/src/pages/public/SchedulingPollPage.tsx
index 452f94aa..2935b1e8 100644
--- a/admin/src/pages/public/SchedulingPollPage.tsx
+++ b/admin/src/pages/public/SchedulingPollPage.tsx
@@ -42,6 +42,7 @@ import {
VOTE_VALUE_LABELS,
} from '@/types/api';
import { useAuthStore } from '@/stores/auth.store';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@@ -153,8 +154,8 @@ export default function SchedulingPollPage() {
setHasVoted(true);
setVoteSuccess(true);
fetchPoll();
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to submit votes');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to submit votes'));
} finally {
setSubmitting(false);
}
diff --git a/admin/src/pages/public/ShortsPage.tsx b/admin/src/pages/public/ShortsPage.tsx
index 131c2bf0..a7551c09 100644
--- a/admin/src/pages/public/ShortsPage.tsx
+++ b/admin/src/pages/public/ShortsPage.tsx
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router-dom';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import LiveChat from '@/components/media/LiveChat';
+import axios from 'axios';
const { useBreakpoint } = Grid;
@@ -465,8 +466,8 @@ export default function ShortsPage() {
}
message.success(data.upvoted ? 'Upvoted!' : 'Upvote removed');
- } catch (error: any) {
- if (error.response?.status === 401) {
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Log in to upvote');
} else {
message.error('Failed to upvote');
diff --git a/admin/src/pages/sms/NewConversationModal.tsx b/admin/src/pages/sms/NewConversationModal.tsx
index d3748165..685351ab 100644
--- a/admin/src/pages/sms/NewConversationModal.tsx
+++ b/admin/src/pages/sms/NewConversationModal.tsx
@@ -4,6 +4,7 @@ import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useDebounce } from '@/hooks/useDebounce';
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@@ -83,9 +84,8 @@ export default function NewConversationModal({ open, onClose, onCreated }: Props
message.success('Message queued');
onCreated(data);
handleReset();
- } catch (err: any) {
- const errMsg = err.response?.data?.error || 'Failed to send message';
- message.error(errMsg);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to send message'));
} finally {
setSending(false);
}
diff --git a/admin/src/pages/social/ChallengesAdminPage.tsx b/admin/src/pages/social/ChallengesAdminPage.tsx
index fa57e8d8..a14cb66b 100644
--- a/admin/src/pages/social/ChallengesAdminPage.tsx
+++ b/admin/src/pages/social/ChallengesAdminPage.tsx
@@ -28,6 +28,8 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import type { PaginationMeta } from '@/types/api';
+import axios from 'axios';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input;
const { RangePicker } = DatePicker;
@@ -128,8 +130,8 @@ export default function ChallengesAdminPage() {
}
setModalOpen(false);
load();
- } catch (err: any) {
- if (err.response?.data?.error?.message) {
+ } catch (err: unknown) {
+ if (axios.isAxiosError(err) && err.response?.data?.error?.message) {
message.error(err.response.data.error.message);
}
} finally {
@@ -147,8 +149,8 @@ export default function ChallengesAdminPage() {
message.success(`Challenge ${action}d`);
}
load();
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || `Failed to ${action}`);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, `Failed to ${action}`));
}
};
diff --git a/admin/src/pages/social/SpotlightAdminPage.tsx b/admin/src/pages/social/SpotlightAdminPage.tsx
index 48056d25..5a6f0d61 100644
--- a/admin/src/pages/social/SpotlightAdminPage.tsx
+++ b/admin/src/pages/social/SpotlightAdminPage.tsx
@@ -10,6 +10,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text } = Typography;
const { TextArea } = Input;
@@ -99,8 +100,8 @@ export default function SpotlightAdminPage() {
setNominateOpen(false);
nominateForm.resetFields();
fetchSpotlights(1);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to nominate');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to nominate'));
}
};
@@ -109,8 +110,8 @@ export default function SpotlightAdminPage() {
await api.post(`/social/spotlight/admin/${id}/approve`);
message.success('Spotlight approved');
fetchSpotlights(pagination.page);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to approve');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to approve'));
}
};
@@ -123,8 +124,8 @@ export default function SpotlightAdminPage() {
setFeatureOpen(false);
setFeatureMonth(null);
fetchSpotlights(pagination.page);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to feature');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to feature'));
}
};
@@ -133,8 +134,8 @@ export default function SpotlightAdminPage() {
await api.post(`/social/spotlight/admin/${id}/archive`);
message.success('Spotlight archived');
fetchSpotlights(pagination.page);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to archive');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to archive'));
}
};
@@ -145,8 +146,8 @@ export default function SpotlightAdminPage() {
message.success('Spotlight updated');
setEditOpen(false);
fetchSpotlights(pagination.page);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to update');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to update'));
}
};
@@ -155,8 +156,8 @@ export default function SpotlightAdminPage() {
await api.delete(`/social/spotlight/admin/${id}`);
message.success('Spotlight deleted');
fetchSpotlights(pagination.page);
- } catch (err: any) {
- message.error(err?.response?.data?.error?.message || 'Failed to delete');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to delete'));
}
};
diff --git a/admin/src/pages/volunteer/ChallengeDetailPage.tsx b/admin/src/pages/volunteer/ChallengeDetailPage.tsx
index d0ba6040..bf382880 100644
--- a/admin/src/pages/volunteer/ChallengeDetailPage.tsx
+++ b/admin/src/pages/volunteer/ChallengeDetailPage.tsx
@@ -12,6 +12,7 @@ import { useAuthStore } from '@/stores/auth.store';
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
import ChallengeLeaderboard from '@/components/social/ChallengeLeaderboard';
import TeamJoinCard from '@/components/social/TeamJoinCard';
+import { getErrorMessage } from '@/utils/getErrorMessage';
interface ChallengeDetail {
id: string;
@@ -77,8 +78,8 @@ export default function ChallengeDetailPage() {
await api.post(`/social/challenges/${id}/teams/${myTeam.id}/leave`);
message.success('Left team');
load();
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to leave team');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to leave team'));
} finally {
setLeaving(false);
}
diff --git a/admin/src/pages/volunteer/FriendCalendarPage.tsx b/admin/src/pages/volunteer/FriendCalendarPage.tsx
index 9c050ab5..a868786d 100644
--- a/admin/src/pages/volunteer/FriendCalendarPage.tsx
+++ b/admin/src/pages/volunteer/FriendCalendarPage.tsx
@@ -24,6 +24,7 @@ import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
import type { PersonalCalendarItem } from '@/types/api';
+import axios from 'axios';
const { Title, Text } = Typography;
@@ -50,8 +51,8 @@ export default function FriendCalendarPage() {
{ params: { startDate, endDate } },
);
setItems(data.items);
- } catch (err: any) {
- if (err.response?.status === 403) {
+ } catch (err: unknown) {
+ if (axios.isAxiosError(err) && err.response?.status === 403) {
message.error('This user has not shared their calendar');
}
} finally {
diff --git a/admin/src/pages/volunteer/GroupDetailPage.tsx b/admin/src/pages/volunteer/GroupDetailPage.tsx
index a7f1160a..90d7306e 100644
--- a/admin/src/pages/volunteer/GroupDetailPage.tsx
+++ b/admin/src/pages/volunteer/GroupDetailPage.tsx
@@ -9,6 +9,7 @@ import type { SocialGroupDetail } from '@/types/social';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import dayjs from 'dayjs';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text } = Typography;
@@ -37,8 +38,8 @@ export default function GroupDetailPage() {
try {
const { data } = await api.get(`/social/groups/${id}`);
setGroup(data);
- } catch (err: any) {
- setError(err.response?.data?.error?.message || 'Failed to load group');
+ } catch (err: unknown) {
+ setError(getErrorMessage(err, 'Failed to load group'));
} finally {
setLoading(false);
}
@@ -56,8 +57,8 @@ export default function GroupDetailPage() {
const { data } = await api.post<{ token: string; room: string; slug: string }>(`/social/groups/${id}/call/token`);
window.open(`/meet/${data.slug}`, '_blank');
fetchGroup();
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to start call');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to start call'));
} finally {
setCallLoading(false);
}
@@ -68,8 +69,8 @@ export default function GroupDetailPage() {
try {
const { data } = await api.post<{ token: string; room: string; slug: string }>(`/social/groups/${id}/call/token`);
window.open(`/meet/${data.slug}`, '_blank');
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to join call');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to join call'));
} finally {
setCallLoading(false);
}
@@ -86,8 +87,8 @@ export default function GroupDetailPage() {
await api.post(`/social/groups/${id}/call/end`);
message.success('Call ended');
fetchGroup();
- } catch (err: any) {
- message.error(err.response?.data?.error?.message || 'Failed to end call');
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to end call'));
}
},
});
diff --git a/admin/src/pages/volunteer/SocialProfilePage.tsx b/admin/src/pages/volunteer/SocialProfilePage.tsx
index cf9d51a6..7d3fe9d6 100644
--- a/admin/src/pages/volunteer/SocialProfilePage.tsx
+++ b/admin/src/pages/volunteer/SocialProfilePage.tsx
@@ -10,6 +10,7 @@ import PokeButton from '@/components/social/PokeButton';
import MessageButton from '@/components/social/MessageButton';
import type { SocialProfile, SocialProfileMe, AchievementWithProgress } from '@/types/social';
import dayjs from 'dayjs';
+import { getErrorMessage } from '@/utils/getErrorMessage';
export default function SocialProfilePage() {
const { userId } = useParams<{ userId: string }>();
@@ -41,8 +42,8 @@ export default function SocialProfilePage() {
setProfileOther(profileRes.data);
setAchievements(achRes.data.achievements || []);
}
- } catch (err: any) {
- setError(err.response?.data?.error?.message || 'Failed to load profile');
+ } catch (err: unknown) {
+ setError(getErrorMessage(err, 'Failed to load profile'));
} finally {
setLoading(false);
}
diff --git a/admin/src/pages/volunteer/VolunteerShiftsPage.tsx b/admin/src/pages/volunteer/VolunteerShiftsPage.tsx
index 4bd60253..6fee41d6 100644
--- a/admin/src/pages/volunteer/VolunteerShiftsPage.tsx
+++ b/admin/src/pages/volunteer/VolunteerShiftsPage.tsx
@@ -29,6 +29,7 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { useCanvassStore } from '@/stores/canvass.store';
import FriendsAttendingBadge from '@/components/social/FriendsAttendingBadge';
+import { getErrorMessage } from '@/utils/getErrorMessage';
const { Title, Text, Paragraph } = Typography;
@@ -132,9 +133,8 @@ export default function VolunteerShiftsPage() {
await api.post(`/map/shifts/volunteer/${shift.id}/signup`);
message.success('Signed up successfully!');
fetchUpcoming();
- } catch (err: any) {
- const msg = err.response?.data?.error?.message || 'Failed to sign up';
- message.error(msg);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to sign up'));
} finally {
setActionLoading(null);
}
@@ -155,9 +155,8 @@ export default function VolunteerShiftsPage() {
message.success('Signup cancelled');
if (tab === 'upcoming') fetchUpcoming();
else fetchSignups();
- } catch (err: any) {
- const msg = err.response?.data?.error?.message || 'Failed to cancel signup';
- message.error(msg);
+ } catch (err: unknown) {
+ message.error(getErrorMessage(err, 'Failed to cancel signup'));
} finally {
setActionLoading(null);
}
diff --git a/admin/src/stores/tour.store.ts b/admin/src/stores/tour.store.ts
new file mode 100644
index 00000000..4f7d311f
--- /dev/null
+++ b/admin/src/stores/tour.store.ts
@@ -0,0 +1,167 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type { TourSectionId, SectionProgress } from '@/components/tour/types';
+
+interface TourState {
+ /** Schema version for migration */
+ storeVersion: number;
+ /** Legacy flag — kept for backward compat */
+ tourCompleted: boolean;
+ /** Per-section progress map */
+ sections: Record;
+ /** Currently active section (ephemeral, not persisted) */
+ activeSectionId: TourSectionId | null;
+ /** Current step within active section (ephemeral) */
+ activeStep: number;
+ /** Tour Hub drawer open state (ephemeral) */
+ hubOpen: boolean;
+}
+
+interface TourActions {
+ openHub: () => void;
+ closeHub: () => void;
+ launchSection: (sectionId: TourSectionId, totalSteps: number) => void;
+ setStep: (step: number) => void;
+ completeActiveSection: () => void;
+ pauseActiveSection: () => void;
+ resetSection: (sectionId: TourSectionId) => void;
+ resetAllSections: () => void;
+ /** Legacy compat */
+ completeTour: () => void;
+ resetTour: () => void;
+}
+
+export const useTourStore = create()(
+ persist(
+ (set, get) => ({
+ storeVersion: 2,
+ tourCompleted: false,
+ sections: {},
+ activeSectionId: null,
+ activeStep: 0,
+ hubOpen: false,
+
+ openHub: () => set({ hubOpen: true }),
+ closeHub: () => set({ hubOpen: false }),
+
+ launchSection: (sectionId, totalSteps) => {
+ const existing = get().sections[sectionId];
+ const startStep = existing && !existing.completed ? existing.currentStep : 0;
+ set({
+ activeSectionId: sectionId,
+ activeStep: startStep,
+ hubOpen: false,
+ sections: {
+ ...get().sections,
+ [sectionId]: {
+ currentStep: startStep,
+ totalSteps,
+ completed: false,
+ lastActiveAt: new Date().toISOString(),
+ },
+ },
+ });
+ },
+
+ setStep: (step) => {
+ const { activeSectionId, sections } = get();
+ if (!activeSectionId) return;
+ const existing = sections[activeSectionId] || { currentStep: 0, totalSteps: 0, completed: false, lastActiveAt: null };
+ set({
+ activeStep: step,
+ sections: {
+ ...sections,
+ [activeSectionId]: {
+ ...existing,
+ currentStep: step,
+ lastActiveAt: new Date().toISOString(),
+ },
+ },
+ });
+ },
+
+ completeActiveSection: () => {
+ const { activeSectionId, sections } = get();
+ if (!activeSectionId) return;
+ const existing = sections[activeSectionId] || { currentStep: 0, totalSteps: 0, completed: false, lastActiveAt: null };
+ set({
+ activeSectionId: null,
+ activeStep: 0,
+ sections: {
+ ...sections,
+ [activeSectionId]: {
+ ...existing,
+ completed: true,
+ lastActiveAt: new Date().toISOString(),
+ },
+ },
+ });
+ },
+
+ pauseActiveSection: () => {
+ set({ activeSectionId: null, activeStep: 0 });
+ },
+
+ resetSection: (sectionId) => {
+ const { sections } = get();
+ const updated = { ...sections };
+ delete updated[sectionId];
+ set({ sections: updated });
+ },
+
+ resetAllSections: () => {
+ set({ sections: {}, tourCompleted: false });
+ },
+
+ // Legacy compat — maps to getting-started section
+ completeTour: () => {
+ set({
+ tourCompleted: true,
+ sections: {
+ ...get().sections,
+ 'getting-started': {
+ currentStep: 0,
+ totalSteps: 7,
+ completed: true,
+ lastActiveAt: new Date().toISOString(),
+ },
+ },
+ });
+ },
+
+ resetTour: () => {
+ const { sections } = get();
+ const updated = { ...sections };
+ delete updated['getting-started'];
+ set({ tourCompleted: false, sections: updated });
+ },
+ }),
+ {
+ name: 'cml-tour',
+ version: 2,
+ migrate: (persisted: unknown, version: number) => {
+ if (version < 2) {
+ // Old shape: { tourCompleted: boolean, tourVersion: number }
+ const old = persisted as { tourCompleted?: boolean } | null;
+ const wasCompleted = old?.tourCompleted === true;
+ return {
+ storeVersion: 2,
+ tourCompleted: wasCompleted,
+ sections: wasCompleted
+ ? { 'getting-started': { currentStep: 0, totalSteps: 7, completed: true, lastActiveAt: null } }
+ : {},
+ activeSectionId: null,
+ activeStep: 0,
+ hubOpen: false,
+ };
+ }
+ return persisted as TourState & TourActions;
+ },
+ partialize: (state) => ({
+ storeVersion: state.storeVersion,
+ tourCompleted: state.tourCompleted,
+ sections: state.sections,
+ }) as unknown as TourState & TourActions,
+ }
+ )
+);
diff --git a/admin/src/utils/getErrorMessage.ts b/admin/src/utils/getErrorMessage.ts
new file mode 100644
index 00000000..e16288c6
--- /dev/null
+++ b/admin/src/utils/getErrorMessage.ts
@@ -0,0 +1,15 @@
+import axios from 'axios';
+
+/**
+ * Safely extract an error message from an unknown error value.
+ * Handles Axios errors (API responses), standard Error objects, and fallback.
+ */
+export function getErrorMessage(err: unknown, fallback: string): string {
+ if (axios.isAxiosError(err)) {
+ return err.response?.data?.message || err.response?.data?.error?.message || fallback;
+ }
+ if (err instanceof Error) {
+ return err.message;
+ }
+ return fallback;
+}
diff --git a/api/package-lock.json b/api/package-lock.json
index 27a431aa..e3f74fa5 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -43,6 +43,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
+ "winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
@@ -3653,6 +3654,14 @@
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
},
+ "node_modules/file-stream-rotator": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
+ "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
+ "dependencies": {
+ "moment": "^2.29.1"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -4391,6 +4400,14 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -4535,6 +4552,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5693,6 +5718,23 @@
"node": ">= 12.0.0"
}
},
+ "node_modules/winston-daily-rotate-file": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
+ "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
+ "dependencies": {
+ "file-stream-rotator": "^0.6.1",
+ "object-hash": "^3.0.0",
+ "triple-beam": "^1.4.1",
+ "winston-transport": "^4.7.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "peerDependencies": {
+ "winston": "^3"
+ }
+ },
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
diff --git a/api/package.json b/api/package.json
index 51f9b9a4..e2b6f21c 100644
--- a/api/package.json
+++ b/api/package.json
@@ -51,6 +51,7 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
+ "winston-daily-rotate-file": "^5.0.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts
index 21e5664a..dd406d50 100644
--- a/api/prisma/seed.ts
+++ b/api/prisma/seed.ts
@@ -58,6 +58,10 @@ async function main() {
});
if (admin) {
console.log(`ℹ️ Found existing admin user: ${admin.email}`);
+ } else {
+ console.error('❌ FATAL: No SUPER_ADMIN user exists and none could be created.');
+ console.error(' Fix INITIAL_ADMIN_PASSWORD in .env (12+ chars, uppercase, lowercase, digit)');
+ process.exit(2);
}
}
diff --git a/api/src/config/env.ts b/api/src/config/env.ts
index 3a248814..dd75c795 100644
--- a/api/src/config/env.ts
+++ b/api/src/config/env.ts
@@ -11,6 +11,12 @@ const envSchema = z.object({
ADMIN_URL: z.string().default('http://localhost:3000'),
DOMAIN: z.string().default('cmlite.org'),
+ // Logging
+ LOG_DIR: z.string().default('/app/logs'),
+
+ // Security
+ CSP_ENABLED: z.string().default('false'),
+
// Bunker Ops (Fleet Management)
INSTANCE_LABEL: z.string().default(''),
BUNKER_OPS_ENABLED: z.string().default('false'),
diff --git a/api/src/media-server.ts b/api/src/media-server.ts
index 12549117..ad484095 100644
--- a/api/src/media-server.ts
+++ b/api/src/media-server.ts
@@ -30,6 +30,7 @@ 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';
+import { mediaErrorHandler } from './modules/media/middleware/error-handler';
// Add BigInt serialization support for Prisma BigInt fields
// This converts BigInt values to strings when JSON.stringify() is called
@@ -45,6 +46,8 @@ const fastify = Fastify({
trustProxy: true,
});
+fastify.setErrorHandler(mediaErrorHandler);
+
// Graceful shutdown handler
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
diff --git a/api/src/middleware/correlation-id.ts b/api/src/middleware/correlation-id.ts
new file mode 100644
index 00000000..4bbcf6d7
--- /dev/null
+++ b/api/src/middleware/correlation-id.ts
@@ -0,0 +1,16 @@
+import { randomUUID } from 'crypto';
+import { Request, Response, NextFunction } from 'express';
+
+const CORRELATION_HEADER = 'x-request-id';
+
+/**
+ * Middleware that assigns a unique correlation ID to each request.
+ * Uses the incoming x-request-id header if present, otherwise generates a new UUID.
+ * Sets the correlation ID on both the request object and response header.
+ */
+export function correlationId(req: Request, res: Response, next: NextFunction) {
+ const id = (req.headers[CORRELATION_HEADER] as string) || randomUUID();
+ req.correlationId = id;
+ res.setHeader(CORRELATION_HEADER, id);
+ next();
+}
diff --git a/api/src/middleware/error-handler.ts b/api/src/middleware/error-handler.ts
index b9e08b68..b72b2de5 100644
--- a/api/src/middleware/error-handler.ts
+++ b/api/src/middleware/error-handler.ts
@@ -16,7 +16,7 @@ export class AppError extends Error {
export function errorHandler(
err: Error,
- _req: Request,
+ req: Request,
res: Response,
_next: NextFunction
) {
@@ -47,7 +47,13 @@ export function errorHandler(
return;
}
- logger.error('Unhandled error:', err);
+ logger.error('Unhandled error', {
+ error: err.message,
+ stack: err.stack,
+ correlationId: req.correlationId,
+ path: req.path,
+ method: req.method,
+ });
res.status(500).json({
error: {
diff --git a/api/src/modules/docs/docs-reset.service.ts b/api/src/modules/docs/docs-reset.service.ts
new file mode 100644
index 00000000..30a7e816
--- /dev/null
+++ b/api/src/modules/docs/docs-reset.service.ts
@@ -0,0 +1,404 @@
+import { cp, rm, readdir, mkdir, writeFile, stat } from 'fs/promises';
+import path from 'path';
+import { env } from '../../config/env';
+import { logger } from '../../utils/logger';
+import { docsFilesService } from './docs-files.service';
+import { mkdocsConfigService } from './mkdocs-config.service';
+
+const PRESERVED_DIRS = [
+ 'hooks',
+ 'assets',
+ 'javascripts',
+ 'overrides',
+ 'stylesheets',
+ 'blog',
+ 'comments',
+ 'partials',
+ 'includes',
+];
+
+const BASELINE_INDEX_MD = `# Welcome to Your MkDocs Site
+
+This site has been reset to baseline configuration.
+
+## Getting Started
+
+- Edit \`docs/index.md\` to change this page
+- Add new pages in the \`docs/\` directory
+- Configure navigation in \`mkdocs.yml\`
+- Customize the theme in \`docs/overrides/\`
+
+## Features Preserved
+
+Your custom code has been preserved in:
+
+- \`hooks/\` - Custom build hooks
+- \`assets/\` - Images and static files
+- \`javascripts/\` - Custom JavaScript
+- \`overrides/\` - Theme overrides
+- \`stylesheets/\` - Custom CSS
+- \`blog/\` - Blog content
+
+## Next Steps
+
+1. Start adding your content
+2. Configure the navigation
+3. Customize the appearance
+4. Deploy your site
+
+---
+
+*Built with MkDocs Material*
+`;
+
+const BASELINE_GETTING_STARTED_MD = `# Getting Started
+
+Welcome to your fresh MkDocs Material site!
+
+## Adding Content
+
+Create new markdown files in the \`docs/\` directory:
+
+\`\`\`bash
+docs/
+\u251c\u2500\u2500 index.md # Homepage
+\u251c\u2500\u2500 getting-started.md # This page
+\u251c\u2500\u2500 page1.md # Your content
+\u2514\u2500\u2500 page2.md # More content
+\`\`\`
+
+## Configuring Navigation
+
+Edit \`mkdocs.yml\` to add navigation:
+
+\`\`\`yaml
+nav:
+ - Home: index.md
+ - Getting Started: getting-started.md
+ - Your Section:
+ - Page 1: page1.md
+ - Page 2: page2.md
+\`\`\`
+
+## Using the Blog
+
+The blog plugin is already configured. Add posts in \`docs/blog/posts/\`:
+
+\`\`\`markdown
+---
+date: 2024-01-01
+categories:
+ - News
+---
+
+# Your Blog Post Title
+
+Post content here...
+\`\`\`
+
+## Customization
+
+- Theme overrides: \`docs/overrides/\`
+- Custom CSS: \`docs/stylesheets/\`
+- Custom JS: \`docs/javascripts/\`
+`;
+
+const HOME_HTML = `{% extends "main.html" %}
+
+{% block extrahead %}
+{{ super() }}
+
+{% endblock %}
+
+{% block content %}
+
+
+ {% if config.theme.logo %}
+
+ {% endif %}
+
+
Let's get started!
+
+
+ Your MkDocs Material site is ready for customization.
+
+
+
+
+
+
+
+
📝
+
Write Content
+
Create pages with Markdown
+
+
+
+
🎨
+
Customize Theme
+
Make it your own
+
+
+
+
🚀
+
Deploy
+
Share with the world
+
+
+
+{% endblock %}
+`;
+
+const HOME_CSS = `/* Simple home page styles */
+
+.home-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+.home-hero {
+ text-align: center;
+ padding: 4rem 0;
+}
+
+.home-logo {
+ width: 120px;
+ height: 120px;
+ margin-bottom: 2rem;
+}
+
+.home-title {
+ font-size: 3rem;
+ font-weight: 700;
+ margin: 0 0 1rem 0;
+ color: var(--md-primary-fg-color);
+}
+
+.home-subtitle {
+ font-size: 1.25rem;
+ color: var(--md-default-fg-color--light);
+ margin: 0 0 2rem 0;
+}
+
+.home-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.home-button {
+ display: inline-block;
+ padding: 0.75rem 2rem;
+ border-radius: 0.25rem;
+ text-decoration: none;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.home-button-primary {
+ background: var(--md-primary-fg-color);
+ color: var(--md-primary-bg-color);
+}
+
+.home-button-primary:hover {
+ background: var(--md-primary-fg-color--dark);
+ transform: translateY(-2px);
+}
+
+.home-button-secondary {
+ border: 2px solid var(--md-primary-fg-color);
+ color: var(--md-primary-fg-color);
+}
+
+.home-button-secondary:hover {
+ background: var(--md-primary-fg-color);
+ color: var(--md-primary-bg-color);
+}
+
+.home-features {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 2rem;
+ margin-top: 4rem;
+}
+
+.feature-card {
+ text-align: center;
+ padding: 2rem;
+ border-radius: 0.5rem;
+ background: var(--md-code-bg-color);
+}
+
+.feature-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+}
+
+.feature-card h3 {
+ margin: 0 0 0.5rem 0;
+ color: var(--md-default-fg-color);
+}
+
+.feature-card p {
+ margin: 0;
+ color: var(--md-default-fg-color--light);
+}
+
+/* Dark mode support */
+[data-md-color-scheme="slate"] .home-logo {
+ filter: brightness(0.9);
+}
+
+[data-md-color-scheme="slate"] .feature-card {
+ background: var(--md-default-fg-color--lightest);
+}
+
+/* Mobile responsive */
+@media (max-width: 768px) {
+ .home-title {
+ font-size: 2rem;
+ }
+
+ .home-subtitle {
+ font-size: 1rem;
+ }
+
+ .home-actions {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .home-button {
+ width: 100%;
+ max-width: 300px;
+ text-align: center;
+ }
+}
+`;
+
+interface ResetResult {
+ success: boolean;
+ backupPath: string;
+ filesReset: number;
+ filesPreserved: number;
+}
+
+async function resetToBaseline(): Promise {
+ const docsDir = path.resolve(env.MKDOCS_DOCS_PATH);
+ const mkdocsRoot = path.dirname(env.MKDOCS_CONFIG_PATH);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '_').slice(0, 15);
+ const backupDir = path.join(mkdocsRoot, 'backups', `docs_backup_${timestamp}`);
+ const tempDir = path.join('/tmp', `mkdocs-reset-${process.pid}`);
+
+ logger.info('Starting docs reset to baseline', { docsDir, backupDir });
+
+ // Step a: Create timestamped backup
+ await mkdir(backupDir, { recursive: true });
+ await cp(docsDir, path.join(backupDir, 'docs'), { recursive: true });
+ await cp(env.MKDOCS_CONFIG_PATH, path.join(backupDir, 'mkdocs.yml'));
+ logger.info('Backup created', { backupDir });
+
+ // Step b: Save preserved directories to temp
+ await mkdir(tempDir, { recursive: true });
+ let filesPreserved = 0;
+
+ for (const dir of PRESERVED_DIRS) {
+ const srcDir = path.join(docsDir, dir);
+ try {
+ const info = await stat(srcDir);
+ if (info.isDirectory()) {
+ await cp(srcDir, path.join(tempDir, dir), { recursive: true });
+ filesPreserved++;
+ logger.info(`Preserved directory: ${dir}`);
+ }
+ } catch {
+ // Directory doesn't exist, skip
+ }
+ }
+
+ // Step c: Clear the docs directory
+ const entries = await readdir(docsDir);
+ for (const entry of entries) {
+ await rm(path.join(docsDir, entry), { recursive: true, force: true });
+ }
+ logger.info('Docs directory cleared');
+
+ // Step d: Write baseline content
+ let filesReset = 0;
+ await writeFile(path.join(docsDir, 'index.md'), BASELINE_INDEX_MD, 'utf-8');
+ filesReset++;
+ await writeFile(path.join(docsDir, 'getting-started.md'), BASELINE_GETTING_STARTED_MD, 'utf-8');
+ filesReset++;
+
+ // Step e: Restore preserved directories from temp
+ for (const dir of PRESERVED_DIRS) {
+ const tempSrcDir = path.join(tempDir, dir);
+ try {
+ const info = await stat(tempSrcDir);
+ if (info.isDirectory()) {
+ await cp(tempSrcDir, path.join(docsDir, dir), { recursive: true });
+ logger.info(`Restored directory: ${dir}`);
+ }
+ } catch {
+ // Wasn't preserved, create empty directory
+ await mkdir(path.join(docsDir, dir), { recursive: true });
+ }
+ }
+
+ // Step f: Create home.html and home.css templates if they don't exist in preserved overrides/stylesheets
+ const homeHtmlPath = path.join(docsDir, 'overrides', 'home.html');
+ try {
+ await stat(homeHtmlPath);
+ logger.info('Existing home.html preserved');
+ } catch {
+ await mkdir(path.join(docsDir, 'overrides'), { recursive: true });
+ await writeFile(homeHtmlPath, HOME_HTML, 'utf-8');
+ filesReset++;
+ logger.info('Created baseline home.html');
+ }
+
+ const homeCssPath = path.join(docsDir, 'stylesheets', 'home.css');
+ try {
+ await stat(homeCssPath);
+ logger.info('Existing home.css preserved');
+ } catch {
+ await mkdir(path.join(docsDir, 'stylesheets'), { recursive: true });
+ await writeFile(homeCssPath, HOME_CSS, 'utf-8');
+ filesReset++;
+ logger.info('Created baseline home.css');
+ }
+
+ // Clean up temp directory
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
+
+ // Step g: Invalidate docs file tree cache
+ await docsFilesService.invalidateTreeCache();
+
+ // Step h: Trigger a build
+ const buildResult = await mkdocsConfigService.triggerBuild();
+ if (!buildResult.success) {
+ logger.warn('MkDocs build after reset did not succeed', { output: buildResult.output });
+ }
+
+ logger.info('Docs reset to baseline complete', { filesReset, filesPreserved, backupDir });
+
+ return {
+ success: true,
+ backupPath: backupDir,
+ filesReset,
+ filesPreserved,
+ };
+}
+
+export const docsResetService = {
+ resetToBaseline,
+};
diff --git a/api/src/modules/docs/docs.routes.ts b/api/src/modules/docs/docs.routes.ts
index c3984ad1..ea2cbcf6 100644
--- a/api/src/modules/docs/docs.routes.ts
+++ b/api/src/modules/docs/docs.routes.ts
@@ -14,6 +14,7 @@ import { docsCollabService } from './docs-collab.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
+import { docsResetService } from './docs-reset.service';
const router = Router();
router.use(authenticate);
@@ -114,6 +115,21 @@ router.post(
},
);
+// POST /api/docs/reset — reset docs content to baseline
+router.post(
+ '/reset',
+ requireRole('SUPER_ADMIN'),
+ async (_req: Request, res: Response, next: NextFunction) => {
+ try {
+ const result = await docsResetService.resetToBaseline();
+ res.json(result);
+ } catch (err) {
+ logger.error('Docs reset failed', err);
+ next(err);
+ }
+ },
+);
+
// --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config (content editors only)
diff --git a/api/src/modules/media/middleware/error-handler.ts b/api/src/modules/media/middleware/error-handler.ts
new file mode 100644
index 00000000..1dba8bf6
--- /dev/null
+++ b/api/src/modules/media/middleware/error-handler.ts
@@ -0,0 +1,20 @@
+import { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
+import { logger } from '../../../utils/logger';
+
+export function mediaErrorHandler(
+ error: FastifyError | Error,
+ request: FastifyRequest,
+ reply: FastifyReply
+) {
+ logger.error('Media API error', {
+ error: error.message,
+ stack: error.stack,
+ url: request.url,
+ method: request.method,
+ });
+
+ const statusCode = 'statusCode' in error ? error.statusCode ?? 500 : 500;
+ reply.status(statusCode).send({
+ message: statusCode === 500 ? 'Internal server error' : error.message,
+ });
+}
diff --git a/api/src/modules/media/routes/chat-stream.routes.ts b/api/src/modules/media/routes/chat-stream.routes.ts
index 13780357..37d42ead 100644
--- a/api/src/modules/media/routes/chat-stream.routes.ts
+++ b/api/src/modules/media/routes/chat-stream.routes.ts
@@ -1,6 +1,7 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import Redis from 'ioredis';
import { env } from '../../../config/env.js';
+import { logger } from '../../../utils/logger';
/**
* SSE Chat Stream Routes
@@ -94,7 +95,7 @@ export function broadcastCommentToVideo(
});
redis.publish(channel, message);
} catch (error) {
- console.error('Failed to broadcast comment:', error);
+ logger.error('Failed to broadcast comment:', error);
}
}
@@ -114,7 +115,7 @@ export function broadcastReactionToVideo(
});
redis.publish(channel, message);
} catch (error) {
- console.error('Failed to broadcast reaction:', error);
+ logger.error('Failed to broadcast reaction:', error);
}
}
diff --git a/api/src/modules/media/routes/chat-threads.routes.ts b/api/src/modules/media/routes/chat-threads.routes.ts
index 3685fa37..a18835b2 100644
--- a/api/src/modules/media/routes/chat-threads.routes.ts
+++ b/api/src/modules/media/routes/chat-threads.routes.ts
@@ -1,6 +1,7 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { authenticate } from '../middleware/auth';
+import { logger } from '../../../utils/logger';
interface ThreadsQuery {
limit?: string;
@@ -111,7 +112,7 @@ export async function chatThreadsRoutes(fastify: FastifyInstance) {
return reply.send({ threads: paginated, total: threads.length });
} catch (error) {
- console.error('Failed to fetch chat threads:', error);
+ logger.error('Failed to fetch chat threads:', error);
return reply.code(500).send({ message: 'Failed to fetch chat threads' });
}
}
@@ -153,7 +154,7 @@ export async function chatThreadsRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Thread marked as read' });
} catch (error) {
- console.error('Failed to mark thread as read:', error);
+ logger.error('Failed to mark thread as read:', error);
return reply.code(500).send({ message: 'Failed to mark thread as read' });
}
}
diff --git a/api/src/modules/media/routes/comment-admin.routes.ts b/api/src/modules/media/routes/comment-admin.routes.ts
index 2d98ad9a..7f2a2bb7 100644
--- a/api/src/modules/media/routes/comment-admin.routes.ts
+++ b/api/src/modules/media/routes/comment-admin.routes.ts
@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { invalidateWordListCache } from '../services/word-filter.service';
+import { logger } from '../../../utils/logger';
interface ListCommentsQuery {
page?: string;
@@ -56,7 +57,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ total, pending, flagged, hidden, safe });
} catch (error) {
- console.error('Failed to fetch comment stats:', error);
+ logger.error('Failed to fetch comment stats:', error);
return reply.code(500).send({ message: 'Failed to fetch comment stats' });
}
}
@@ -163,7 +164,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
totalPages: Math.ceil(total / limit),
});
} catch (error) {
- console.error('Failed to fetch admin comments:', error);
+ logger.error('Failed to fetch admin comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
@@ -217,7 +218,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment approved' });
} catch (error) {
- console.error('Failed to approve comment:', error);
+ logger.error('Failed to approve comment:', error);
return reply.code(500).send({ message: 'Failed to approve comment' });
}
}
@@ -275,7 +276,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment hidden' });
} catch (error) {
- console.error('Failed to hide comment:', error);
+ logger.error('Failed to hide comment:', error);
return reply.code(500).send({ message: 'Failed to hide comment' });
}
}
@@ -325,7 +326,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment unhidden' });
} catch (error) {
- console.error('Failed to unhide comment:', error);
+ logger.error('Failed to unhide comment:', error);
return reply.code(500).send({ message: 'Failed to unhide comment' });
}
}
@@ -359,7 +360,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Notes updated' });
} catch (error) {
- console.error('Failed to update notes:', error);
+ logger.error('Failed to update notes:', error);
return reply.code(500).send({ message: 'Failed to update notes' });
}
}
@@ -394,7 +395,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Comment deleted' });
} catch (error) {
- console.error('Failed to delete comment:', error);
+ logger.error('Failed to delete comment:', error);
return reply.code(500).send({ message: 'Failed to delete comment' });
}
}
@@ -421,7 +422,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ words });
} catch (error) {
- console.error('Failed to fetch word filters:', error);
+ logger.error('Failed to fetch word filters:', error);
return reply.code(500).send({ message: 'Failed to fetch word filters' });
}
}
@@ -469,7 +470,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.code(201).send(entry);
} catch (error) {
- console.error('Failed to add word filter:', error);
+ logger.error('Failed to add word filter:', error);
return reply.code(500).send({ message: 'Failed to add word filter' });
}
}
@@ -497,7 +498,7 @@ export async function commentAdminRoutes(fastify: FastifyInstance) {
return reply.send({ message: 'Word filter removed' });
} catch (error) {
- console.error('Failed to delete word filter:', error);
+ logger.error('Failed to delete word filter:', error);
return reply.code(500).send({ message: 'Failed to delete word filter' });
}
}
diff --git a/api/src/modules/media/routes/comments.routes.ts b/api/src/modules/media/routes/comments.routes.ts
index 122062fc..5342aff5 100644
--- a/api/src/modules/media/routes/comments.routes.ts
+++ b/api/src/modules/media/routes/comments.routes.ts
@@ -5,6 +5,7 @@ import { broadcastCommentToVideo } from './chat-stream.routes.js';
import { optionalAuth } from '../middleware/auth';
import { checkContent } from '../services/word-filter.service';
import { notifyUser } from './chat-notifications.routes';
+import { logger } from '../../../utils/logger';
// Rate limiting map: userId/sessionId -> array of timestamps
const commentRateLimitMap = new Map();
@@ -87,7 +88,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
}),
});
} catch (error) {
- console.error('Failed to fetch comments:', error);
+ logger.error('Failed to fetch comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
}
@@ -272,13 +273,13 @@ export async function commentsRoutes(fastify: FastifyInstance) {
}
} catch (notifyErr) {
// Non-critical: don't fail the comment creation
- console.error('Failed to send chat notifications:', notifyErr);
+ logger.error('Failed to send chat notifications:', notifyErr);
}
}
return reply.code(201).send(broadcastData);
} catch (error) {
- console.error('Failed to create comment:', error);
+ logger.error('Failed to create comment:', error);
return reply.code(500).send({ message: 'Failed to create comment' });
}
}
diff --git a/api/src/modules/media/routes/public.routes.ts b/api/src/modules/media/routes/public.routes.ts
index 4723fea9..91031be5 100644
--- a/api/src/modules/media/routes/public.routes.ts
+++ b/api/src/modules/media/routes/public.routes.ts
@@ -57,8 +57,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
if (sort === 'oldest') {
orderBy = { publishedAt: 'asc' };
} else if (sort === 'popular') {
- // TODO: Sort by view count when analytics are implemented
- orderBy = { publishedAt: 'desc' };
+ orderBy = { viewCount: 'desc' };
}
const videos = await prisma.video.findMany({
diff --git a/api/src/server.ts b/api/src/server.ts
index b5b7d560..3da0d2dc 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -1,3 +1,5 @@
+import { existsSync, unlinkSync } from 'fs';
+import path from 'path';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
@@ -122,6 +124,7 @@ import { agendaRouter } from './modules/meetings/agenda.routes';
import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
+import { correlationId } from './middleware/correlation-id';
const app = express();
@@ -129,8 +132,26 @@ const app = express();
app.set('trust proxy', 1);
// --- Middleware Stack ---
+app.use(correlationId);
+
app.use(helmet({
- contentSecurityPolicy: env.NODE_ENV === 'production' ? undefined : false,
+ contentSecurityPolicy: env.CSP_ENABLED === 'true'
+ ? {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
+ fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
+ imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
+ connectSrc: ["'self'", 'wss:', 'ws:'],
+ frameSrc: ["'self'", `*.${env.DOMAIN}`],
+ frameAncestors: ["'self'", `*.${env.DOMAIN}`],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'"],
+ },
+ }
+ : false,
}));
app.use(cors({
@@ -174,9 +195,11 @@ app.use((req, res, next) => {
});
// --- Health Check ---
-app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
+app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
const checks: Record = {};
+ const detailed = req.query.detailed === 'true';
+ // Core checks (always run — used by Docker healthcheck)
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
@@ -191,8 +214,34 @@ app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
checks.redis = 'error';
}
- const healthy = Object.values(checks).every(v => v === 'ok');
- res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
+ // Extended checks (opt-in, for monitoring/debugging)
+ if (detailed) {
+ // MkDocs dev server
+ try {
+ const mkdocsRes = await fetch(`http://${env.MKDOCS_CONTAINER_NAME}:8000`, { signal: AbortSignal.timeout(3000) });
+ checks.mkdocs = mkdocsRes.ok ? 'ok' : 'error';
+ } catch {
+ checks.mkdocs = 'error';
+ }
+
+ // Disk space (logs directory)
+ try {
+ const { statfs } = await import('fs/promises');
+ const stats = await statfs(env.LOG_DIR);
+ const freeGB = Number(stats.bavail) * Number(stats.bsize) / (1024 ** 3);
+ checks.disk = freeGB > 1 ? 'ok' : 'warning';
+ checks.diskFreeGB = freeGB.toFixed(1);
+ } catch {
+ checks.disk = 'unknown';
+ }
+ }
+
+ const coreHealthy = checks.database === 'ok' && checks.redis === 'ok';
+ res.status(coreHealthy ? 200 : 503).json({
+ status: coreHealthy ? 'healthy' : 'degraded',
+ version: process.env.npm_package_version || 'unknown',
+ checks,
+ });
});
// --- Metrics Endpoint (authenticated - SUPER_ADMIN only) ---
@@ -417,6 +466,18 @@ async function start() {
logger.warn('Startup sync of MkDocs overrides failed:', err);
});
+ // Check for docs reset flag (set by config.sh during setup)
+ const docsResetFlagPath = path.resolve(path.dirname(env.MKDOCS_CONFIG_PATH), '.reset-docs-on-startup');
+ if (existsSync(docsResetFlagPath)) {
+ const { docsResetService } = await import('./modules/docs/docs-reset.service');
+ docsResetService.resetToBaseline()
+ .then((result) => {
+ logger.info(`Docs reset completed: ${result.filesReset} files reset, ${result.filesPreserved} preserved`);
+ unlinkSync(docsResetFlagPath);
+ })
+ .catch((err) => logger.warn('Docs reset from config flag failed:', err));
+ }
+
// Validate MkDocs exports on startup (recurring runs handled by scheduled-jobs queue)
pagesService.validateExports()
.then(({ validated, repaired, errors }) => {
diff --git a/api/src/types/express.d.ts b/api/src/types/express.d.ts
index 492ae557..edf1438e 100644
--- a/api/src/types/express.d.ts
+++ b/api/src/types/express.d.ts
@@ -9,6 +9,7 @@ declare global {
role: UserRole;
roles: UserRole[];
};
+ correlationId?: string;
}
}
}
diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts
index 6427828c..0ad8af2f 100644
--- a/api/src/utils/logger.ts
+++ b/api/src/utils/logger.ts
@@ -1,6 +1,16 @@
import winston from 'winston';
import { env } from '../config/env';
+const consoleFormat = winston.format.combine(
+ winston.format.colorize(),
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
+ const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
+ return `${timestamp} [${level}]: ${message}${metaStr}`;
+ })
+);
+
+const transports: winston.transport[] = [new winston.transports.Console()];
+
export const logger = winston.createLogger({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
@@ -8,13 +18,31 @@ export const logger = winston.createLogger({
winston.format.errors({ stack: true }),
env.NODE_ENV === 'production'
? winston.format.json()
- : winston.format.combine(
- winston.format.colorize(),
- winston.format.printf(({ timestamp, level, message, ...meta }) => {
- const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
- return `${timestamp} [${level}]: ${message}${metaStr}`;
- })
- )
+ : consoleFormat
),
- transports: [new winston.transports.Console()],
+ transports,
});
+
+// Add file transport in production (dynamic import to avoid breaking dev when not installed)
+if (env.NODE_ENV === 'production') {
+ import('winston-daily-rotate-file').then((mod) => {
+ const DailyRotateFile = mod.default;
+ logger.add(
+ new DailyRotateFile({
+ dirname: env.LOG_DIR,
+ filename: 'api-%DATE%.log',
+ datePattern: 'YYYY-MM-DD',
+ maxSize: '20m',
+ maxFiles: '14d',
+ zippedArchive: true,
+ format: winston.format.combine(
+ winston.format.timestamp(),
+ winston.format.errors({ stack: true }),
+ winston.format.json(),
+ ),
+ })
+ );
+ }).catch(() => {
+ logger.warn('winston-daily-rotate-file not installed, file logging disabled');
+ });
+}
diff --git a/config.sh b/config.sh
index 2afd2ace..73c163e5 100755
--- a/config.sh
+++ b/config.sh
@@ -1113,6 +1113,22 @@ YAML
success "Generated services.yaml for Homepage dashboard"
}
+# =============================================================================
+# Documentation Site Reset
+# =============================================================================
+
+configure_docs_reset() {
+ header "Documentation Site"
+
+ if prompt_yes_no "Reset documentation site to baseline? (keeps header & tracking)"; then
+ touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup"
+ success "Docs reset scheduled for first startup"
+ info "Custom code (header, analytics, hooks, assets) will be preserved"
+ else
+ info "Keeping existing documentation content"
+ fi
+}
+
# =============================================================================
# Directory Permissions
# =============================================================================
@@ -1373,6 +1389,7 @@ main() {
configure_cors
generate_nginx_configs
generate_services_yaml
+ configure_docs_reset
fix_container_permissions
install_upgrade_watcher
install_backup_timer
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index e5e0ff1c..66e2a8e5 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -34,6 +34,7 @@ services:
environment:
- NODE_ENV=${NODE_ENV:-development}
- PORT=4000
+ - LOG_DIR=/app/logs
- DATABASE_URL=postgresql://${V2_POSTGRES_USER:-changemaker}:${V2_POSTGRES_PASSWORD:?V2_POSTGRES_PASSWORD must be set in .env}@changemaker-v2-postgres:5432/${V2_POSTGRES_DB:-changemaker_v2}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
@@ -122,6 +123,7 @@ services:
- ./data:/data:ro
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
+ - ./logs/api:/app/logs
deploy:
resources:
limits:
diff --git a/docker-compose.yml b/docker-compose.yml
index 7ad32191..979221f0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,6 +33,7 @@ services:
environment:
- NODE_ENV=${NODE_ENV:-development}
- PORT=4000
+ - LOG_DIR=/app/logs
- DATABASE_URL=postgresql://${V2_POSTGRES_USER:-changemaker}:${V2_POSTGRES_PASSWORD:?V2_POSTGRES_PASSWORD must be set in .env}@changemaker-v2-postgres:5432/${V2_POSTGRES_DB:-changemaker_v2}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
@@ -123,6 +124,7 @@ services:
- ./data:/data:ro
- ./data/upgrade:/app/upgrade:rw
- ./configs:/app/configs:ro
+ - ./logs/api:/app/logs
deploy:
resources:
limits:
diff --git a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
index 159085f7..1c310e1b 100644
--- a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
+++ b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
@@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 0,
- "updated_at": "2026-03-23T15:48:06-06:00",
+ "updated_at": "2026-03-25T20:11:01-06:00",
"created_at": "2025-05-28T14:54:59-06:00",
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
"default_branch": "main",
- "last_build_update": "2026-03-23T15:48:06-06:00"
+ "last_build_update": "2026-03-25T20:11:01-06:00"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/anthropics-claude-code.json b/mkdocs/docs/assets/repo-data/anthropics-claude-code.json
index 86f4e3f6..f5bf5c8d 100644
--- a/mkdocs/docs/assets/repo-data/anthropics-claude-code.json
+++ b/mkdocs/docs/assets/repo-data/anthropics-claude-code.json
@@ -4,13 +4,13 @@
"description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.",
"html_url": "https://github.com/anthropics/claude-code",
"language": "Shell",
- "stars_count": 81725,
- "forks_count": 6816,
- "open_issues_count": 7445,
- "updated_at": "2026-03-23T23:45:06Z",
+ "stars_count": 82863,
+ "forks_count": 6945,
+ "open_issues_count": 7902,
+ "updated_at": "2026-03-26T05:46:26Z",
"created_at": "2025-02-22T17:41:21Z",
"clone_url": "https://github.com/anthropics/claude-code.git",
"ssh_url": "git@github.com:anthropics/claude-code.git",
"default_branch": "main",
- "last_build_update": "2026-03-20T22:24:50Z"
+ "last_build_update": "2026-03-26T00:31:05Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/coder-code-server.json b/mkdocs/docs/assets/repo-data/coder-code-server.json
index 192a8e2c..c1778600 100644
--- a/mkdocs/docs/assets/repo-data/coder-code-server.json
+++ b/mkdocs/docs/assets/repo-data/coder-code-server.json
@@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
- "stars_count": 76802,
+ "stars_count": 76836,
"forks_count": 6567,
- "open_issues_count": 167,
- "updated_at": "2026-03-23T23:30:09Z",
+ "open_issues_count": 166,
+ "updated_at": "2026-03-26T04:51:01Z",
"created_at": "2019-02-27T16:50:41Z",
"clone_url": "https://github.com/coder/code-server.git",
"ssh_url": "git@github.com:coder/code-server.git",
"default_branch": "main",
- "last_build_update": "2026-03-23T18:50:37Z"
+ "last_build_update": "2026-03-25T23:42:46Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/gethomepage-homepage.json b/mkdocs/docs/assets/repo-data/gethomepage-homepage.json
index 46b3bc59..918e35c4 100644
--- a/mkdocs/docs/assets/repo-data/gethomepage-homepage.json
+++ b/mkdocs/docs/assets/repo-data/gethomepage-homepage.json
@@ -4,13 +4,13 @@
"description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.",
"html_url": "https://github.com/gethomepage/homepage",
"language": "JavaScript",
- "stars_count": 29108,
- "forks_count": 1825,
- "open_issues_count": 1,
- "updated_at": "2026-03-23T22:44:02Z",
+ "stars_count": 29142,
+ "forks_count": 1831,
+ "open_issues_count": 2,
+ "updated_at": "2026-03-26T04:32:12Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",
"default_branch": "dev",
- "last_build_update": "2026-03-23T12:27:34Z"
+ "last_build_update": "2026-03-26T04:09:25Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/go-gitea-gitea.json b/mkdocs/docs/assets/repo-data/go-gitea-gitea.json
index 2a2d1493..9c1b719f 100644
--- a/mkdocs/docs/assets/repo-data/go-gitea-gitea.json
+++ b/mkdocs/docs/assets/repo-data/go-gitea-gitea.json
@@ -4,13 +4,13 @@
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
"html_url": "https://github.com/go-gitea/gitea",
"language": "Go",
- "stars_count": 54439,
- "forks_count": 6485,
+ "stars_count": 54497,
+ "forks_count": 6492,
"open_issues_count": 2870,
- "updated_at": "2026-03-23T23:19:17Z",
+ "updated_at": "2026-03-26T05:41:32Z",
"created_at": "2016-11-01T02:13:26Z",
"clone_url": "https://github.com/go-gitea/gitea.git",
"ssh_url": "git@github.com:go-gitea/gitea.git",
"default_branch": "main",
- "last_build_update": "2026-03-23T23:20:24Z"
+ "last_build_update": "2026-03-26T00:53:32Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/knadh-listmonk.json b/mkdocs/docs/assets/repo-data/knadh-listmonk.json
index 4381eade..50516355 100644
--- a/mkdocs/docs/assets/repo-data/knadh-listmonk.json
+++ b/mkdocs/docs/assets/repo-data/knadh-listmonk.json
@@ -4,13 +4,13 @@
"description": "High performance, self-hosted, newsletter and mailing list manager with a modern dashboard. Single binary app.",
"html_url": "https://github.com/knadh/listmonk",
"language": "Go",
- "stars_count": 19329,
- "forks_count": 1958,
- "open_issues_count": 105,
- "updated_at": "2026-03-23T23:36:16Z",
+ "stars_count": 19343,
+ "forks_count": 1965,
+ "open_issues_count": 103,
+ "updated_at": "2026-03-26T04:23:08Z",
"created_at": "2019-06-26T05:08:39Z",
"clone_url": "https://github.com/knadh/listmonk.git",
"ssh_url": "git@github.com:knadh/listmonk.git",
"default_branch": "master",
- "last_build_update": "2026-03-23T11:44:19Z"
+ "last_build_update": "2026-03-26T04:23:38Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json b/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json
index c67a8a41..0e80408f 100644
--- a/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json
+++ b/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json
@@ -4,10 +4,10 @@
"description": "Create & scan cute qr codes easily \ud83d\udc7e",
"html_url": "https://github.com/lyqht/mini-qr",
"language": "Vue",
- "stars_count": 1928,
- "forks_count": 243,
- "open_issues_count": 20,
- "updated_at": "2026-03-23T14:42:50Z",
+ "stars_count": 1931,
+ "forks_count": 244,
+ "open_issues_count": 21,
+ "updated_at": "2026-03-26T02:38:23Z",
"created_at": "2023-04-21T14:20:14Z",
"clone_url": "https://github.com/lyqht/mini-qr.git",
"ssh_url": "git@github.com:lyqht/mini-qr.git",
diff --git a/mkdocs/docs/assets/repo-data/n8n-io-n8n.json b/mkdocs/docs/assets/repo-data/n8n-io-n8n.json
index d57c7a2f..4d2e67ba 100644
--- a/mkdocs/docs/assets/repo-data/n8n-io-n8n.json
+++ b/mkdocs/docs/assets/repo-data/n8n-io-n8n.json
@@ -4,13 +4,13 @@
"description": "Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.",
"html_url": "https://github.com/n8n-io/n8n",
"language": "TypeScript",
- "stars_count": 180706,
- "forks_count": 56089,
- "open_issues_count": 1433,
- "updated_at": "2026-03-23T23:47:25Z",
+ "stars_count": 181103,
+ "forks_count": 56170,
+ "open_issues_count": 1416,
+ "updated_at": "2026-03-26T05:48:22Z",
"created_at": "2019-06-22T09:24:21Z",
"clone_url": "https://github.com/n8n-io/n8n.git",
"ssh_url": "git@github.com:n8n-io/n8n.git",
"default_branch": "master",
- "last_build_update": "2026-03-23T22:55:38Z"
+ "last_build_update": "2026-03-26T05:30:58Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/nocodb-nocodb.json b/mkdocs/docs/assets/repo-data/nocodb-nocodb.json
index 9072cbd1..d391743b 100644
--- a/mkdocs/docs/assets/repo-data/nocodb-nocodb.json
+++ b/mkdocs/docs/assets/repo-data/nocodb-nocodb.json
@@ -4,13 +4,13 @@
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
"html_url": "https://github.com/nocodb/nocodb",
"language": "TypeScript",
- "stars_count": 62544,
- "forks_count": 4679,
- "open_issues_count": 647,
- "updated_at": "2026-03-23T22:41:15Z",
+ "stars_count": 62543,
+ "forks_count": 4681,
+ "open_issues_count": 658,
+ "updated_at": "2026-03-26T05:48:04Z",
"created_at": "2017-10-29T18:51:48Z",
"clone_url": "https://github.com/nocodb/nocodb.git",
"ssh_url": "git@github.com:nocodb/nocodb.git",
"default_branch": "develop",
- "last_build_update": "2026-03-23T19:39:55Z"
+ "last_build_update": "2026-03-26T05:48:41Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/ollama-ollama.json b/mkdocs/docs/assets/repo-data/ollama-ollama.json
index b042dc06..64aba706 100644
--- a/mkdocs/docs/assets/repo-data/ollama-ollama.json
+++ b/mkdocs/docs/assets/repo-data/ollama-ollama.json
@@ -4,13 +4,13 @@
"description": "Get up and running with Kimi-K2.5, GLM-5, MiniMax, DeepSeek, gpt-oss, Qwen, Gemma and other models.",
"html_url": "https://github.com/ollama/ollama",
"language": "Go",
- "stars_count": 165968,
- "forks_count": 15121,
- "open_issues_count": 2708,
- "updated_at": "2026-03-23T23:47:27Z",
+ "stars_count": 166179,
+ "forks_count": 15178,
+ "open_issues_count": 2726,
+ "updated_at": "2026-03-26T05:38:58Z",
"created_at": "2023-06-26T19:39:32Z",
"clone_url": "https://github.com/ollama/ollama.git",
"ssh_url": "git@github.com:ollama/ollama.git",
"default_branch": "main",
- "last_build_update": "2026-03-23T23:31:24Z"
+ "last_build_update": "2026-03-26T02:01:29Z"
}
\ No newline at end of file
diff --git a/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json b/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json
index 66f348b2..b209ab35 100644
--- a/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json
+++ b/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json
@@ -4,13 +4,13 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
- "stars_count": 26370,
- "forks_count": 4058,
- "open_issues_count": 1,
- "updated_at": "2026-03-23T21:42:58Z",
+ "stars_count": 26394,
+ "forks_count": 4060,
+ "open_issues_count": 2,
+ "updated_at": "2026-03-26T02:34:14Z",
"created_at": "2016-01-28T22:09:23Z",
"clone_url": "https://github.com/squidfunk/mkdocs-material.git",
"ssh_url": "git@github.com:squidfunk/mkdocs-material.git",
"default_branch": "master",
- "last_build_update": "2026-03-22T15:57:47Z"
+ "last_build_update": "2026-03-25T22:14:34Z"
}
\ No newline at end of file
diff --git a/nginx/.dockerignore b/nginx/.dockerignore
new file mode 100644
index 00000000..d9729a1e
--- /dev/null
+++ b/nginx/.dockerignore
@@ -0,0 +1,3 @@
+.git
+*.log
+*.md
diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh
index 655a6753..70ba0c2d 100755
--- a/scripts/build-and-push.sh
+++ b/scripts/build-and-push.sh
@@ -137,7 +137,11 @@ done
echo ""
if [[ ${#FAILED[@]} -eq 0 ]]; then
- success "All services built${NO_PUSH:+ (not pushed)}."
+ if [[ "$NO_PUSH" == "true" ]]; then
+ success "All services built (not pushed)."
+ else
+ success "All services built and pushed."
+ fi
echo ""
if [[ "$NO_PUSH" == "false" ]]; then
info "Images available in registry:"
diff --git a/scripts/restore.sh b/scripts/restore.sh
new file mode 100755
index 00000000..3afbfd72
--- /dev/null
+++ b/scripts/restore.sh
@@ -0,0 +1,280 @@
+#!/usr/bin/env bash
+# =============================================================================
+# Changemaker Lite V2 — Restore Script
+# Restores from a backup archive created by backup.sh.
+# Usage: ./scripts/restore.sh --archive PATH [--skip-db] [--skip-uploads]
+# [--skip-listmonk] [--dry-run] [--force]
+# =============================================================================
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+# --- Colors ---
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+error() { echo -e "${RED}ERROR:${NC} $1"; }
+warn() { echo -e "${YELLOW}WARN:${NC} $1"; }
+info() { echo -e "${CYAN}INFO:${NC} $1"; }
+ok() { echo -e "${GREEN}OK:${NC} $1"; }
+
+# --- Defaults ---
+ARCHIVE=""
+SKIP_DB=false
+SKIP_UPLOADS=false
+SKIP_LISTMONK=false
+DRY_RUN=false
+FORCE=false
+
+# --- Parse args ---
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --archive) ARCHIVE="$2"; shift 2 ;;
+ --skip-db) SKIP_DB=true; shift ;;
+ --skip-uploads) SKIP_UPLOADS=true; shift ;;
+ --skip-listmonk) SKIP_LISTMONK=true; shift ;;
+ --dry-run) DRY_RUN=true; shift ;;
+ --force) FORCE=true; shift ;;
+ --help)
+ echo "Usage: $0 --archive PATH [OPTIONS]"
+ echo ""
+ echo "Options:"
+ echo " --archive PATH Path to backup .tar.gz archive (required)"
+ echo " --skip-db Skip main PostgreSQL restore"
+ echo " --skip-uploads Skip uploads directory restore"
+ echo " --skip-listmonk Skip Listmonk database restore"
+ echo " --dry-run Validate archive without restoring"
+ echo " --force Skip confirmation prompt"
+ echo ""
+ exit 0 ;;
+ *) error "Unknown option: $1"; exit 1 ;;
+ esac
+done
+
+if [[ -z "$ARCHIVE" ]]; then
+ error "Missing --archive PATH argument"
+ echo " Usage: $0 --archive /path/to/backup.tar.gz"
+ exit 1
+fi
+
+if [[ ! -f "$ARCHIVE" ]]; then
+ error "Archive not found: $ARCHIVE"
+ exit 1
+fi
+
+# --- Load .env ---
+if [ -f "$PROJECT_DIR/.env" ]; then
+ while IFS='=' read -r key value; do
+ [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
+ key="$(echo "$key" | xargs)"
+ value="${value%\"}" ; value="${value#\"}"
+ value="${value%\'}" ; value="${value#\'}"
+ if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
+ export "$key=$value"
+ fi
+ done < "$PROJECT_DIR/.env"
+fi
+
+# --- Derived vars ---
+PG_CONTAINER="${PG_CONTAINER:-changemaker-v2-postgres}"
+PG_USER="${V2_POSTGRES_USER:-changemaker}"
+PG_DB="${V2_POSTGRES_DB:-changemaker_v2}"
+LISTMONK_PG_CONTAINER="${LISTMONK_PG_CONTAINER:-listmonk-db}"
+LISTMONK_PG_USER="${LISTMONK_DB_USER:-listmonk}"
+LISTMONK_PG_DB="${LISTMONK_DB_NAME:-listmonk}"
+UPLOADS_DIR="${PROJECT_DIR}/assets/uploads"
+
+APP_CONTAINERS="changemaker-v2-api changemaker-v2-admin changemaker-media-api changemaker-v2-nginx"
+
+echo ""
+echo "=========================================="
+echo " Changemaker Lite V2 — Restore"
+echo "=========================================="
+echo ""
+
+# --- 1. Extract and validate archive ---
+info "Extracting archive: $(basename "$ARCHIVE")"
+TEMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TEMP_DIR"' EXIT
+
+tar -xzf "$ARCHIVE" -C "$TEMP_DIR"
+
+# Find the extracted backup directory (single child of temp dir)
+BACKUP_DIR="$(find "$TEMP_DIR" -mindepth 1 -maxdepth 1 -type d | head -1)"
+if [[ -z "$BACKUP_DIR" ]]; then
+ error "Archive does not contain a backup directory"
+ exit 1
+fi
+
+# Validate manifest
+MANIFEST="${BACKUP_DIR}/manifest.json"
+if [[ ! -f "$MANIFEST" ]]; then
+ error "manifest.json not found in archive"
+ exit 1
+fi
+
+ok "Manifest found"
+echo " Backup: $(python3 -c "import json,sys; m=json.load(open(sys.argv[1])); print(m.get('backup_name','unknown'))" "$MANIFEST" 2>/dev/null || basename "$BACKUP_DIR")"
+
+# --- 2. Verify checksums ---
+info "Verifying file integrity..."
+INTEGRITY_OK=true
+while IFS= read -r entry; do
+ FILE=$(echo "$entry" | python3 -c "import json,sys; print(json.load(sys.stdin)['file'])")
+ EXPECTED=$(echo "$entry" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha256'])")
+ FILE_PATH="${BACKUP_DIR}/${FILE}"
+
+ if [[ ! -f "$FILE_PATH" ]]; then
+ warn "Missing file: $FILE"
+ continue
+ fi
+
+ ACTUAL="$(sha256sum "$FILE_PATH" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$FILE_PATH" | cut -d' ' -f1)"
+ if [[ "$EXPECTED" != "$ACTUAL" ]]; then
+ error "Checksum mismatch: $FILE"
+ INTEGRITY_OK=false
+ else
+ ok " $FILE checksum verified"
+ fi
+done < <(python3 -c "import json,sys; [print(json.dumps(f)) for f in json.load(open(sys.argv[1]))['files']]" "$MANIFEST")
+
+if ! $INTEGRITY_OK; then
+ error "Archive integrity check failed. Aborting."
+ exit 1
+fi
+
+# --- Show what will be restored ---
+echo ""
+info "Components to restore:"
+[[ -f "${BACKUP_DIR}/v2-postgres.sql.gz" ]] && ! $SKIP_DB && echo " - V2 PostgreSQL (${PG_DB})"
+[[ -f "${BACKUP_DIR}/gancio-postgres.sql.gz" ]] && ! $SKIP_DB && echo " - Gancio PostgreSQL"
+[[ -f "${BACKUP_DIR}/listmonk-postgres.sql.gz" ]] && ! $SKIP_LISTMONK && echo " - Listmonk PostgreSQL (${LISTMONK_PG_DB})"
+[[ -f "${BACKUP_DIR}/uploads.tar.gz" ]] && ! $SKIP_UPLOADS && echo " - Uploads directory"
+echo ""
+
+if $DRY_RUN; then
+ ok "Dry run complete. Archive is valid."
+ exit 0
+fi
+
+# --- Confirmation ---
+if ! $FORCE; then
+ echo -e "${RED}WARNING: This will OVERWRITE existing databases and uploads!${NC}"
+ read -p "Continue with restore? (y/N): " CONFIRM
+ if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 0
+ fi
+fi
+
+# --- 3. Stop application containers ---
+info "Stopping application containers..."
+for container in $APP_CONTAINERS; do
+ if docker ps --format '{{.Names}}' | grep -q "^${container}$"; then
+ docker stop "$container" >/dev/null 2>&1 && echo " Stopped $container" || true
+ fi
+done
+echo ""
+
+# --- 4. Restore V2 PostgreSQL ---
+if [[ -f "${BACKUP_DIR}/v2-postgres.sql.gz" ]] && ! $SKIP_DB; then
+ info "Restoring V2 PostgreSQL (${PG_DB})..."
+ if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
+ # Terminate existing connections and recreate database
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${PG_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "DROP DATABASE IF EXISTS \"${PG_DB}\";" >/dev/null 2>&1
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "CREATE DATABASE \"${PG_DB}\";" >/dev/null 2>&1
+ # Restore dump
+ gunzip -c "${BACKUP_DIR}/v2-postgres.sql.gz" | docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1
+ ok "V2 PostgreSQL restored"
+ else
+ error "Container ${PG_CONTAINER} not running"
+ fi
+fi
+
+# --- 4b. Restore Gancio PostgreSQL ---
+if [[ -f "${BACKUP_DIR}/gancio-postgres.sql.gz" ]] && ! $SKIP_DB; then
+ info "Restoring Gancio PostgreSQL..."
+ if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='gancio' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "DROP DATABASE IF EXISTS gancio;" >/dev/null 2>&1
+ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres -c \
+ "CREATE DATABASE gancio;" >/dev/null 2>&1
+ gunzip -c "${BACKUP_DIR}/gancio-postgres.sql.gz" | docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d gancio >/dev/null 2>&1
+ ok "Gancio PostgreSQL restored"
+ else
+ warn "V2 PostgreSQL container not running, skipping Gancio restore"
+ fi
+fi
+
+# --- 5. Restore Listmonk PostgreSQL ---
+if [[ -f "${BACKUP_DIR}/listmonk-postgres.sql.gz" ]] && ! $SKIP_LISTMONK; then
+ info "Restoring Listmonk PostgreSQL (${LISTMONK_PG_DB})..."
+ if docker ps --format '{{.Names}}' | grep -q "^${LISTMONK_PG_CONTAINER}$"; then
+ docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${LISTMONK_PG_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
+ docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \
+ "DROP DATABASE IF EXISTS \"${LISTMONK_PG_DB}\";" >/dev/null 2>&1
+ docker exec "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d postgres -c \
+ "CREATE DATABASE \"${LISTMONK_PG_DB}\";" >/dev/null 2>&1
+ gunzip -c "${BACKUP_DIR}/listmonk-postgres.sql.gz" | docker exec -i "$LISTMONK_PG_CONTAINER" psql -U "$LISTMONK_PG_USER" -d "$LISTMONK_PG_DB" >/dev/null 2>&1
+ ok "Listmonk PostgreSQL restored"
+ else
+ warn "Container ${LISTMONK_PG_CONTAINER} not running, skipping"
+ fi
+fi
+
+# --- 6. Restore uploads ---
+if [[ -f "${BACKUP_DIR}/uploads.tar.gz" ]] && ! $SKIP_UPLOADS; then
+ info "Restoring uploads..."
+ UPLOADS_PARENT="$(dirname "$UPLOADS_DIR")"
+ mkdir -p "$UPLOADS_PARENT"
+ tar -xzf "${BACKUP_DIR}/uploads.tar.gz" -C "$UPLOADS_PARENT"
+ ok "Uploads restored to $UPLOADS_DIR"
+fi
+
+echo ""
+
+# --- 7. Run migrations (catch up if code is ahead of backup) ---
+info "Running Prisma migrations..."
+cd "$PROJECT_DIR"
+docker compose run --rm --no-deps --entrypoint "" api npx prisma migrate deploy 2>&1 \
+ && ok "Migrations applied" \
+ || warn "Migration apply had warnings"
+
+# --- 8. Restart application containers ---
+info "Restarting application containers..."
+docker compose up -d 2>&1 | tail -5
+echo ""
+
+# --- 9. Health check ---
+info "Waiting for API health check..."
+HEALTHY=false
+for i in $(seq 1 20); do
+ if docker compose exec -T api wget -q --spider http://localhost:4000/api/health 2>/dev/null; then
+ HEALTHY=true
+ break
+ fi
+ sleep 3
+done
+
+if $HEALTHY; then
+ ok "API is healthy"
+else
+ warn "API health check timed out (60s). Check logs: docker compose logs api"
+fi
+
+echo ""
+echo "=========================================="
+echo -e " ${GREEN}Restore complete!${NC}"
+echo " Archive: $(basename "$ARCHIVE")"
+echo "=========================================="
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 826d72ae..48a08c45 100755
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -1121,30 +1121,35 @@ write_progress 7 "Verification" 90 "Running health checks..."
VERIFY_FAILED=false
-# API health
-if docker compose exec -T api wget -q --spider http://localhost:4000/api/health 2>/dev/null; then
- success "API (port 4000): healthy"
-else
- warn "API (port 4000): not responding"
+# Polling health check helper (retries for up to MAX_WAIT seconds)
+verify_service_health() {
+ local name="$1" check_cmd="$2" max_wait="${3:-30}"
+ local waited=0
+ while [[ $waited -lt $max_wait ]]; do
+ if eval "$check_cmd" 2>/dev/null; then
+ success "$name: healthy (${waited}s)"
+ return 0
+ fi
+ sleep 3
+ waited=$((waited + 3))
+ done
+ warn "$name: not responding after ${max_wait}s"
VERIFY_FAILED=true
-fi
+ return 1
+}
+
+# API health (with polling — may still be running migrations)
+verify_service_health "API (port 4000)" \
+ "docker compose exec -T api wget -q --spider http://localhost:4000/api/health" 45
# Admin health
-if docker compose exec -T admin wget -q --spider http://localhost:3000/ 2>/dev/null; then
- success "Admin (port 3000): healthy"
-else
- warn "Admin (port 3000): not responding"
- VERIFY_FAILED=true
-fi
+verify_service_health "Admin (port 3000)" \
+ "docker compose exec -T admin wget -q --spider http://localhost:3000/" 30
# Media API health (optional — may not be enabled)
if docker ps --format '{{.Names}}' | grep -q 'changemaker-media-api'; then
- if docker compose exec -T media-api wget -q --spider http://127.0.0.1:4100/health 2>/dev/null; then
- success "Media API (port 4100): healthy"
- else
- warn "Media API (port 4100): not responding"
- VERIFY_FAILED=true
- fi
+ verify_service_health "Media API (port 4100)" \
+ "docker compose exec -T media-api wget -q --spider http://127.0.0.1:4100/health" 30
fi
# Gancio health (optional)
diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh
new file mode 100755
index 00000000..58468565
--- /dev/null
+++ b/scripts/validate-env.sh
@@ -0,0 +1,305 @@
+#!/bin/bash
+# =============================================================================
+# validate-env.sh — Validate .env for Changemaker Lite
+#
+# Checks required variables, secret strength, placeholder detection,
+# production-mode requirements, and port conflicts.
+#
+# Usage: ./scripts/validate-env.sh [--strict]
+# --strict: treat warnings as errors (for CI/pre-deploy)
+#
+# Exit codes:
+# 0 = all checks passed
+# 1 = errors found
+# 2 = warnings found (only with --strict)
+# =============================================================================
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+ENV_FILE="${PROJECT_DIR}/.env"
+
+# Colors
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+ERRORS=0
+WARNINGS=0
+STRICT=false
+
+[[ "${1:-}" == "--strict" ]] && STRICT=true
+
+# --- Helpers ---
+
+error() {
+ echo -e " ${RED}ERROR${NC} $1"
+ ERRORS=$((ERRORS + 1))
+}
+
+warn() {
+ echo -e " ${YELLOW}WARN${NC} $1"
+ WARNINGS=$((WARNINGS + 1))
+}
+
+ok() {
+ echo -e " ${GREEN}OK${NC} $1"
+}
+
+info() {
+ echo -e " ${CYAN}INFO${NC} $1"
+}
+
+# --- Load .env ---
+
+if [[ ! -f "$ENV_FILE" ]]; then
+ echo -e "${RED}ERROR: .env file not found at ${ENV_FILE}${NC}"
+ echo " Copy .env.example to .env and configure it:"
+ echo " cp .env.example .env"
+ exit 1
+fi
+
+# Source .env safely (export all, ignore errors from complex values)
+set -a
+# shellcheck disable=SC1090
+source "$ENV_FILE" 2>/dev/null || true
+set +a
+
+echo ""
+echo "========================================="
+echo " Changemaker Lite — Environment Validator"
+echo "========================================="
+echo ""
+
+# --- 1. Required Variables ---
+
+echo "1. Required Variables"
+echo "---------------------"
+
+REQUIRED_VARS=(
+ "V2_POSTGRES_PASSWORD"
+ "REDIS_PASSWORD"
+ "JWT_ACCESS_SECRET"
+ "JWT_REFRESH_SECRET"
+ "JWT_INVITE_SECRET"
+ "DOMAIN"
+ "INITIAL_ADMIN_EMAIL"
+ "INITIAL_ADMIN_PASSWORD"
+)
+
+for var in "${REQUIRED_VARS[@]}"; do
+ val="${!var:-}"
+ if [[ -z "$val" ]]; then
+ error "$var is not set"
+ else
+ ok "$var is set"
+ fi
+done
+
+echo ""
+
+# --- 2. Secret Strength ---
+
+echo "2. Secret Strength"
+echo "------------------"
+
+check_secret_length() {
+ local name="$1"
+ local min_len="$2"
+ local val="${!name:-}"
+ if [[ -n "$val" ]] && [[ ${#val} -lt $min_len ]]; then
+ error "$name is too short (${#val} chars, need $min_len+)"
+ elif [[ -n "$val" ]]; then
+ ok "$name length OK (${#val} chars)"
+ fi
+}
+
+check_secret_length "JWT_ACCESS_SECRET" 32
+check_secret_length "JWT_REFRESH_SECRET" 32
+check_secret_length "JWT_INVITE_SECRET" 32
+check_secret_length "V2_POSTGRES_PASSWORD" 8
+check_secret_length "REDIS_PASSWORD" 8
+
+# Password policy check (12+ chars, uppercase, lowercase, digit)
+ADMIN_PW="${INITIAL_ADMIN_PASSWORD:-}"
+if [[ -n "$ADMIN_PW" ]]; then
+ PW_OK=true
+ if [[ ${#ADMIN_PW} -lt 12 ]]; then
+ error "INITIAL_ADMIN_PASSWORD too short (${#ADMIN_PW} chars, need 12+)"
+ PW_OK=false
+ fi
+ if ! [[ "$ADMIN_PW" =~ [A-Z] ]]; then
+ error "INITIAL_ADMIN_PASSWORD needs at least one uppercase letter"
+ PW_OK=false
+ fi
+ if ! [[ "$ADMIN_PW" =~ [a-z] ]]; then
+ error "INITIAL_ADMIN_PASSWORD needs at least one lowercase letter"
+ PW_OK=false
+ fi
+ if ! [[ "$ADMIN_PW" =~ [0-9] ]]; then
+ error "INITIAL_ADMIN_PASSWORD needs at least one digit"
+ PW_OK=false
+ fi
+ if $PW_OK; then
+ ok "INITIAL_ADMIN_PASSWORD meets password policy"
+ fi
+fi
+
+echo ""
+
+# --- 3. Placeholder Detection ---
+
+echo "3. Placeholder Detection"
+echo "------------------------"
+
+PLACEHOLDER_PATTERNS=("CHANGE_THIS" "REQUIRED" "changeme" "password123" "secret123" "example.com" "your-")
+
+for var in JWT_ACCESS_SECRET JWT_REFRESH_SECRET JWT_INVITE_SECRET V2_POSTGRES_PASSWORD REDIS_PASSWORD ENCRYPTION_KEY; do
+ val="${!var:-}"
+ if [[ -z "$val" ]]; then continue; fi
+ for pattern in "${PLACEHOLDER_PATTERNS[@]}"; do
+ if echo "$val" | grep -qi "$pattern"; then
+ error "$var contains placeholder value '$pattern'"
+ fi
+ done
+done
+
+# Check secrets are not reused
+if [[ -n "${JWT_ACCESS_SECRET:-}" ]] && [[ "${JWT_ACCESS_SECRET:-}" == "${JWT_REFRESH_SECRET:-}" ]]; then
+ error "JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different"
+fi
+
+if [[ -n "${ENCRYPTION_KEY:-}" ]] && [[ "${ENCRYPTION_KEY:-}" == "${JWT_ACCESS_SECRET:-}" ]]; then
+ error "ENCRYPTION_KEY must not reuse JWT_ACCESS_SECRET"
+fi
+
+ok "Placeholder check complete"
+echo ""
+
+# --- 4. Production Checks ---
+
+echo "4. Production Checks"
+echo "--------------------"
+
+NODE_ENV="${NODE_ENV:-development}"
+info "NODE_ENV=$NODE_ENV"
+
+if [[ "$NODE_ENV" == "production" ]]; then
+ if [[ -z "${ENCRYPTION_KEY:-}" ]]; then
+ error "ENCRYPTION_KEY is required in production"
+ else
+ ok "ENCRYPTION_KEY is set"
+ fi
+
+ if [[ "${EMAIL_TEST_MODE:-}" == "true" ]]; then
+ warn "EMAIL_TEST_MODE=true in production (emails go to MailHog, not SMTP)"
+ fi
+
+ CORS="${CORS_ORIGINS:-}"
+ if echo "$CORS" | grep -q "localhost"; then
+ warn "CORS_ORIGINS contains 'localhost' in production"
+ fi
+
+ if [[ -z "${CORS:-}" ]]; then
+ warn "CORS_ORIGINS is not set — API will reject cross-origin requests"
+ else
+ ok "CORS_ORIGINS configured"
+ fi
+else
+ info "Skipping production-only checks (NODE_ENV=$NODE_ENV)"
+fi
+
+echo ""
+
+# --- 5. Port Conflict Detection ---
+
+echo "5. Port Conflict Detection"
+echo "--------------------------"
+
+# Collect all configured ports
+declare -A PORT_MAP
+PORT_VARS=(
+ "ADMIN_PORT:3000"
+ "API_PORT:4000"
+ "MEDIA_API_PORT:4100"
+ "V2_POSTGRES_PORT:5433"
+ "GRAFANA_PORT:3001"
+ "HOMEPAGE_PORT:3010"
+ "GITEA_PORT:3030"
+ "MKDOCS_DEV_PORT:4003"
+ "NOCODB_PORT:8091"
+ "MAILHOG_PORT:8025"
+ "LISTMONK_PORT:9001"
+ "N8N_PORT:5678"
+ "CODE_SERVER_PORT:8888"
+ "PROMETHEUS_PORT:9090"
+)
+
+DUPLICATE_FOUND=false
+for entry in "${PORT_VARS[@]}"; do
+ var="${entry%%:*}"
+ default="${entry##*:}"
+ port="${!var:-$default}"
+
+ if [[ -n "${PORT_MAP[$port]:-}" ]]; then
+ error "Port $port conflict: ${PORT_MAP[$port]} and $var"
+ DUPLICATE_FOUND=true
+ else
+ PORT_MAP[$port]="$var"
+ fi
+done
+
+if ! $DUPLICATE_FOUND; then
+ ok "No port conflicts detected"
+fi
+
+echo ""
+
+# --- 6. Feature Flag Consistency ---
+
+echo "6. Feature Flag Consistency"
+echo "---------------------------"
+
+if [[ "${ENABLE_MEDIA_FEATURES:-}" == "true" ]]; then
+ ok "Media features enabled"
+fi
+
+if [[ "${LISTMONK_SYNC_ENABLED:-}" == "true" ]]; then
+ if [[ -z "${LISTMONK_ADMIN_USER:-}" ]] || [[ -z "${LISTMONK_ADMIN_PASSWORD:-}" ]]; then
+ warn "LISTMONK_SYNC_ENABLED=true but LISTMONK_ADMIN_USER/PASSWORD not set"
+ else
+ ok "Listmonk sync credentials configured"
+ fi
+fi
+
+if [[ "${ENABLE_PAYMENTS:-}" == "true" ]]; then
+ if [[ -z "${STRIPE_SECRET_KEY:-}" ]]; then
+ warn "ENABLE_PAYMENTS=true but STRIPE_SECRET_KEY not set"
+ fi
+fi
+
+echo ""
+
+# --- Summary ---
+
+echo "========================================="
+if [[ $ERRORS -gt 0 ]]; then
+ echo -e " ${RED}FAILED${NC}: $ERRORS error(s), $WARNINGS warning(s)"
+ echo "========================================="
+ exit 1
+elif [[ $WARNINGS -gt 0 ]] && $STRICT; then
+ echo -e " ${YELLOW}WARNINGS${NC}: $WARNINGS warning(s) (strict mode)"
+ echo "========================================="
+ exit 2
+elif [[ $WARNINGS -gt 0 ]]; then
+ echo -e " ${YELLOW}PASSED${NC} with $WARNINGS warning(s)"
+ echo "========================================="
+ exit 0
+else
+ echo -e " ${GREEN}PASSED${NC}: All checks OK"
+ echo "========================================="
+ exit 0
+fi