From 049fa866378802263ef96cabc7774c0785e4860e Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:29:44 +0530 Subject: [PATCH 1/7] fix: restore mood ask popup on dashboard login delay --- .../src/app/(dashboard)/dashboard/page.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/recovery-system/frontend/src/app/(dashboard)/dashboard/page.tsx b/packages/recovery-system/frontend/src/app/(dashboard)/dashboard/page.tsx index a9426db..06d8fc9 100644 --- a/packages/recovery-system/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/packages/recovery-system/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -196,12 +196,18 @@ export default function DashboardPage() { // Show mood popup 2s after load if not logged today useEffect(() => { if (loading) return; - const todayLogged = moodLog.some(e => new Date(e.date).toDateString() === new Date().toDateString()); - if (!todayLogged) { - popTimerRef.current = setTimeout(() => setShowMoodPop(true), 2000); + + // We only show it once per session to not annoy the user on every navigation back to dashboard + const hasShownThisSession = sessionStorage.getItem('moodPopupShown'); + + if (!hasShownThisSession) { + popTimerRef.current = setTimeout(() => { + setShowMoodPop(true); + sessionStorage.setItem('moodPopupShown', 'true'); + }, 2000); } return () => { if (popTimerRef.current) clearTimeout(popTimerRef.current); }; - }, [loading, moodLog]); + }, [loading]); // ── Derived ── const streakDays = streak?.currentStreak ?? 0; From acddb34a534a055c6b40abff8d2cd7e7e5ccd11e Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:51 +0530 Subject: [PATCH 2/7] backend: update app setup --- packages/recovery-system/backend/src/app.ts | 52 +++++++++------------ 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/packages/recovery-system/backend/src/app.ts b/packages/recovery-system/backend/src/app.ts index 07b0968..6b22d8a 100644 --- a/packages/recovery-system/backend/src/app.ts +++ b/packages/recovery-system/backend/src/app.ts @@ -1,6 +1,7 @@ import express, { Application, Request, Response, NextFunction } from "express"; import cors from "cors"; import helmet from "helmet"; +import path from "path"; // ← ADD THIS import { toNodeHandler } from "better-auth/node"; import { auth } from "./lib/auth"; import progressRoutes from "./routes/index"; @@ -13,24 +14,22 @@ import { getProfile, updateProfile, getProfileDetails, signUp } from "./controll import chatRoutes from "./routes/chat.routes"; import gamesRoutes from "./routes/games.routes"; import resourceRoutes from "./routes/resource.routes"; -//import analyzer from 'express-api-timer'; -// import apisnap from '@umeshindu222/apisnap'; - - - const app: Application = express(); app.use(express.json()); -//app.use(analyzer.monitor({ slowThreshold: 300 })); -// CORS Configuration -// TODO: In production, change 'origin' to specific domain (e.g., 'https://relife.com') -// and use process.env.FRONTEND_URL instead of hardcoded localhost +// ── Static file serving for local image uploads ────────────────────────────── +// Images uploaded when Cloudinary is NOT configured are saved to /uploads. +// This middleware serves them at http://localhost:5000/uploads/ +// so the Next.js frontend (port 3000) can load them with an absolute URL. +app.use('/uploads', express.static(path.join(process.cwd(), 'uploads'))); + +// ── CORS ────────────────────────────────────────────────────────────────────── app.use(cors({ origin: process.env.NODE_ENV === 'production' ? process.env.FRONTEND_URL - : true, // Development - allow all (for Postman testing), restricted in production + : true, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] @@ -38,55 +37,49 @@ app.use(cors({ app.use(helmet()); -// Custom auth routes (MUST be before BetterAuth catch-all) +// ── Custom auth routes (MUST be before BetterAuth catch-all) ───────────────── app.get("/api/auth/me", isAuth, getProfile); app.put("/api/auth/profile", isAuth, updateProfile); app.get("/api/auth/profile/details", isAuth, getProfileDetails); app.post("/api/auth/register", signUp); - -// Auth routes (BetterAuth catch-all) +// ── Auth routes (BetterAuth catch-all) ─────────────────────────────────────── app.all("/api/auth/*", (req, res) => toNodeHandler(auth)(req, res)); app.get("/", (_req: Request, res: Response) => { res.status(200).send("Re-Life API is running..."); }); -// Progress Tracking Routes +// ── Progress Tracking Routes ────────────────────────────────────────────────── app.use('/api', progressRoutes); -// Journal Routes +// ── Journal Routes ──────────────────────────────────────────────────────────── app.use('/api', journalRoutes); -// Community Routes +// ── Community Routes ────────────────────────────────────────────────────────── app.use('/api/community', communityRoutes); -// Counselor Routes +// ── Counselor Routes ────────────────────────────────────────────────────────── app.use('/api/counselor', counselorRoutes); -// Booking Routes +// ── Booking Routes ──────────────────────────────────────────────────────────── app.use('/api', bookingRoutes); -// Chat Routes +// ── Chat Routes ─────────────────────────────────────────────────────────────── app.use('/api/chat', chatRoutes); -// Games Routes +// ── Games Routes ───────────────────────────────────────────────────────────── app.use('/api/games', gamesRoutes); -// Resource save Routes +// ── Resource Routes ─────────────────────────────────────────────────────────── app.use('/api/resources', resourceRoutes); -// apisnap.init(app); - -// 404 Handler +// ── 404 Handler ─────────────────────────────────────────────────────────────── app.use((req: Request, res: Response) => { - res.status(404).json({ - message: 'Route not found', - path: req.path - }); + res.status(404).json({ message: 'Route not found', path: req.path }); }); -// Global Error Handler +// ── Global Error Handler ────────────────────────────────────────────────────── app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { console.error('Error:', err); res.status(err.status || 500).json({ @@ -95,5 +88,4 @@ app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { }); }); - export default app; \ No newline at end of file From d911ef9bb6d4a4cbdff2b3b2ba712a812952bb9c Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:51 +0530 Subject: [PATCH 3/7] backend: update journal controller --- .../src/controllers/journal.controller.ts | 329 +++++++++++------- 1 file changed, 208 insertions(+), 121 deletions(-) diff --git a/packages/recovery-system/backend/src/controllers/journal.controller.ts b/packages/recovery-system/backend/src/controllers/journal.controller.ts index d2b2ac6..2cf6ca7 100644 --- a/packages/recovery-system/backend/src/controllers/journal.controller.ts +++ b/packages/recovery-system/backend/src/controllers/journal.controller.ts @@ -1,192 +1,279 @@ import { Request, Response } from 'express'; import Journal from '../models/Journal'; import mongoose from 'mongoose'; +import { getFileUrl } from '../services/upload.service'; +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Safely parse a field that may arrive as a JSON string, a real array, + * or a comma-separated string (common with multipart/form-data). + */ +function parseArrayField(value: unknown): string[] { + if (!value) return []; + if (Array.isArray(value)) return (value as string[]).map(s => String(s).trim()).filter(Boolean); + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : []; + } catch { + return value.split(',').map(s => s.trim()).filter(Boolean); + } + } + return []; +} + +const VALID_MOODS = ['great', 'good', 'okay', 'struggling', 'relapsed'] as const; + +// ── Controllers ─────────────────────────────────────────────────────────────── + +/** + * POST /api/journals + * Create a new journal entry (supports optional image upload via multer) + */ export const createEntry = async (req: Request, res: Response) => { try { - // GET USER ID FROM AUTHENTICATION (like progress controller) - const userId = (req as any).user.id; + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); + + if (!mongoose.Types.ObjectId.isValid(userId)) { + return res.status(400).json({ message: 'Invalid user ID format' }); + } + const { content, mood, triggers, copingStrategies, isPrivate } = req.body; -// Validation + // Validate required fields if (!content || !mood) { return res.status(400).json({ message: 'Content and mood are required' }); } - - // Validate ObjectId format - if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid user ID format.' }); + const trimmed = typeof content === 'string' ? content.trim() : ''; + if (trimmed.length < 10) { + return res.status(400).json({ message: 'Content must be at least 10 characters' }); + } + if (trimmed.length > 10_000) { + return res.status(400).json({ message: 'Content must not exceed 10,000 characters' }); + } + if (!VALID_MOODS.includes(mood)) { + return res.status(400).json({ message: `Invalid mood. Must be one of: ${VALID_MOODS.join(', ')}` }); } - const imageUrl = req.file ? req.file.path : undefined; - - // Parse arrays from form-data strings - const parsedTriggers = typeof triggers === 'string' ? JSON.parse(triggers) : triggers || []; - const parsedCopingStrategies = typeof copingStrategies === 'string' ? JSON.parse(copingStrategies) : copingStrategies || []; + // Resolve image URL (works for both Cloudinary and local disk) + const imageUrl = req.file ? getFileUrl(req.file) : undefined; - const newJournal = new Journal({ - user: userId, - content, + const entry = new Journal({ + user: userId, + content: trimmed, mood, - triggers: parsedTriggers, - copingStrategies: parsedCopingStrategies, - image: imageUrl, - isPrivate: isPrivate === 'true' || isPrivate === true + triggers: parseArrayField(triggers), + copingStrategies: parseArrayField(copingStrategies), + image: imageUrl, + isPrivate: isPrivate === 'true' || isPrivate === true, }); - const savedEntry = await newJournal.save(); - return res.status(201).json(savedEntry); - - } catch (error) { - console.error('Create journal error:', error); - return res.status(500).json({ message: 'Failed to create journal', error: error instanceof Error ? error.message : 'Unknown error' }); + const saved = await entry.save(); + return res.status(201).json(saved); + } catch (err) { + console.error('createEntry error:', err); + return res.status(500).json({ + message: 'Failed to create journal entry', + error: err instanceof Error ? err.message : 'Unknown error', + }); } }; +/** + * GET /api/journals + * Paginated list of journal entries for the current user. + * Query: ?page=1&limit=20&date=YYYY-MM-DD&mood=okay + */ export const getEntries = async (req: Request, res: Response) => { try { - // GET USER ID FROM AUTHENTICATION (like progress controller) - const userId = (req as any).user.id; - const { date, page = '1', limit = '10' } = req.query; - - // Validate ObjectId format + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); + if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid user ID format.' }); + return res.status(400).json({ message: 'Invalid user ID format' }); } - let query: any = { user: userId }; + const { date, page = '1', limit = '20', mood } = req.query; + + const query: Record = { user: userId }; - // Proper date filtering (full day range) if (date && typeof date === 'string') { - const startDate = new Date(date); - startDate.setHours(0, 0, 0, 0); - const endDate = new Date(date); - endDate.setHours(23, 59, 59, 999); - - query.createdAt = { $gte: startDate, $lte: endDate }; + const start = new Date(date); start.setHours(0, 0, 0, 0); + const end = new Date(date); end.setHours(23, 59, 59, 999); + query.createdAt = { $gte: start, $lte: end }; } - const pageNum = parseInt(page as string); - const limitNum = parseInt(limit as string); - const skip = (pageNum - 1) * limitNum; + if (mood && typeof mood === 'string' && VALID_MOODS.includes(mood as any)) { + query.mood = mood; + } - const entries = await Journal.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limitNum) - .select('-__v'); + const pageNum = Math.max(1, parseInt(page as string) || 1); + const limitNum = Math.min(50, Math.max(1, parseInt(limit as string) || 20)); + const skip = (pageNum - 1) * limitNum; - const total = await Journal.countDocuments(query); + const [entries, total] = await Promise.all([ + Journal.find(query).sort({ createdAt: -1 }).skip(skip).limit(limitNum).select('-__v'), + Journal.countDocuments(query), + ]); return res.status(200).json({ entries, - pagination: { - page: pageNum, - limit: limitNum, - total, - totalPages: Math.ceil(total / limitNum) - } + pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) }, }); - - } catch (error) { - console.error('Get journals error:', error); - return res.status(500).json({ message: 'Failed to fetch journals', error: error instanceof Error ? error.message : 'Unknown error' }); + } catch (err) { + console.error('getEntries error:', err); + return res.status(500).json({ message: 'Failed to fetch journals', error: err instanceof Error ? err.message : 'Unknown error' }); } }; -export const getEntryById = async (req: Request, res: Response) => { + +/** + * GET /api/journals/stats + * Aggregated writing stats for the current user. + * IMPORTANT: route must be registered BEFORE /journals/:id + */ +export const getStats = async (req: Request, res: Response) => { try { - // GET USER ID FROM AUTHENTICATION (like progress controller) - const userId = (req as any).user.id; - const { id } = req.params; + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); - // Validate ObjectId format if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid user ID format.' }); + return res.status(400).json({ message: 'Invalid user ID format' }); } - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ message: 'Invalid entry ID' }); + const userObjId = new mongoose.Types.ObjectId(userId); + + const [totals, moodDist, streakDays] = await Promise.all([ + Journal.aggregate([ + { $match: { user: userObjId } }, + { $group: { _id: null, total: { $sum: 1 }, contents: { $push: '$content' } } }, + ]), + Journal.aggregate([ + { $match: { user: userObjId } }, + { $group: { _id: '$mood', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]), + Journal.aggregate([ + { $match: { user: userObjId } }, + { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } } } }, + { $sort: { _id: -1 } }, + ]), + ]); + + const total = totals[0]?.total ?? 0; + const totalWords = (totals[0]?.contents ?? []).reduce( + (sum: number, c: string) => sum + (c?.trim().split(/\s+/).filter(Boolean).length ?? 0), 0, + ); + const topMood = moodDist[0]?._id ?? null; + + // Writing streak (consecutive days with at least one entry) + let streak = 0; + const cursor = new Date(); cursor.setHours(0, 0, 0, 0); + for (const { _id: day } of streakDays) { + if (day === cursor.toISOString().split('T')[0]) { + streak++; + cursor.setDate(cursor.getDate() - 1); + } else break; } + const todayStr = new Date().toISOString().split('T')[0]; + const todayCount = (streakDays[0]?._id === todayStr) ? 1 : 0; + + return res.status(200).json({ + total, + totalWords, + topMood, + writingStreak: streak, + todayEntries: todayCount, + moodDistribution: moodDist.reduce((acc: Record, item: { _id: string; count: number }) => { + acc[item._id] = item.count; return acc; + }, {}), + }); + } catch (err) { + console.error('getStats error:', err); + return res.status(500).json({ message: 'Failed to get journal stats', error: err instanceof Error ? err.message : 'Unknown error' }); + } +}; + +/** + * GET /api/journals/:id + */ +export const getEntryById = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); + + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) return res.status(400).json({ message: 'Invalid entry ID' }); + const entry = await Journal.findOne({ _id: id, user: userId }); - - if (!entry) { - return res.status(404).json({ message: 'Entry not found' }); - } + if (!entry) return res.status(404).json({ message: 'Entry not found' }); return res.status(200).json(entry); - } catch (error) { - console.error('Get entry error:', error); - return res.status(500).json({ message: 'Failed to fetch entry', error: error instanceof Error ? error.message : 'Unknown error' }); + } catch (err) { + console.error('getEntryById error:', err); + return res.status(500).json({ message: 'Failed to fetch entry', error: err instanceof Error ? err.message : 'Unknown error' }); } }; +/** + * PATCH /api/journals/:id + */ export const updateEntry = async (req: Request, res: Response) => { try { - // GET USER ID FROM AUTHENTICATION (like progress controller) - const userId = (req as any).user.id; - const { content, mood, triggers, copingStrategies, isPrivate } = req.body; + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); + const { id } = req.params; - - // Validate ObjectId format - if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid user ID format.' }); - } + if (!mongoose.Types.ObjectId.isValid(id)) return res.status(400).json({ message: 'Invalid entry ID' }); + + const { content, mood, triggers, copingStrategies, isPrivate } = req.body; - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ message: 'Invalid entry ID' }); + if (content !== undefined) { + const trimmed = typeof content === 'string' ? content.trim() : ''; + if (trimmed.length < 10) return res.status(400).json({ message: 'Content must be at least 10 characters' }); + if (trimmed.length > 10_000) return res.status(400).json({ message: 'Content must not exceed 10,000 characters' }); + } + if (mood !== undefined && !VALID_MOODS.includes(mood)) { + return res.status(400).json({ message: `Invalid mood. Must be one of: ${VALID_MOODS.join(', ')}` }); } const entry = await Journal.findOne({ _id: id, user: userId }); - - if (!entry) { - return res.status(404).json({ message: 'Entry not found' }); - } + if (!entry) return res.status(404).json({ message: 'Entry not found' }); - // Update fields - if (content) entry.content = content; - if (mood) entry.mood = mood; - if (triggers) entry.triggers = typeof triggers === 'string' ? JSON.parse(triggers) : triggers; - if (copingStrategies) entry.copingStrategies = typeof copingStrategies === 'string' ? JSON.parse(copingStrategies) : copingStrategies; - if (isPrivate !== undefined) entry.isPrivate = isPrivate === 'true' || isPrivate === true; - - // Handle new image upload - if (req.file) { - entry.image = req.file.path; - } + if (content !== undefined) entry.content = content.trim(); + if (mood !== undefined) entry.mood = mood; + if (triggers !== undefined) entry.triggers = parseArrayField(triggers); + if (copingStrategies !== undefined) entry.copingStrategies = parseArrayField(copingStrategies); + if (isPrivate !== undefined) entry.isPrivate = isPrivate === 'true' || isPrivate === true; + if (req.file) entry.image = getFileUrl(req.file); - const updatedEntry = await entry.save(); - return res.status(200).json(updatedEntry); - } catch (error) { - console.error('Update entry error:', error); - return res.status(500).json({ message: 'Failed to update entry', error: error instanceof Error ? error.message : 'Unknown error' }); + const updated = await entry.save(); + return res.status(200).json(updated); + } catch (err) { + console.error('updateEntry error:', err); + return res.status(500).json({ message: 'Failed to update entry', error: err instanceof Error ? err.message : 'Unknown error' }); } }; +/** + * DELETE /api/journals/:id + */ export const deleteEntry = async (req: Request, res: Response) => { try { - // GET USER ID FROM AUTHENTICATION (like progress controller) - const userId = (req as any).user.id; - const { id } = req.params; - - // Validate ObjectId format - if (!mongoose.Types.ObjectId.isValid(userId)) { - return res.status(400).json({ message: 'Invalid user ID format.' }); - } + const userId = (req as any).user?.id; + if (!userId) return res.status(401).json({ message: 'Unauthorized' }); - if (!mongoose.Types.ObjectId.isValid(id)) { - return res.status(400).json({ message: 'Invalid entry ID' }); - } + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) return res.status(400).json({ message: 'Invalid entry ID' }); const entry = await Journal.findOneAndDelete({ _id: id, user: userId }); - - if (!entry) { - return res.status(404).json({ message: 'Entry not found' }); - } + if (!entry) return res.status(404).json({ message: 'Entry not found' }); return res.status(200).json({ message: 'Entry deleted successfully' }); - } catch (error) { - console.error('Delete entry error:', error); - return res.status(500).json({ message: 'Failed to delete entry', error: error instanceof Error ? error.message : 'Unknown error' }); + } catch (err) { + console.error('deleteEntry error:', err); + return res.status(500).json({ message: 'Failed to delete entry', error: err instanceof Error ? err.message : 'Unknown error' }); } }; \ No newline at end of file From fa96e23f77bc34e9e3e51562df2f6ec58277ce80 Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:51 +0530 Subject: [PATCH 4/7] backend: update journal routes --- .../backend/src/routes/journal.routes.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/recovery-system/backend/src/routes/journal.routes.ts b/packages/recovery-system/backend/src/routes/journal.routes.ts index 3f86672..183b5ab 100644 --- a/packages/recovery-system/backend/src/routes/journal.routes.ts +++ b/packages/recovery-system/backend/src/routes/journal.routes.ts @@ -1,16 +1,28 @@ import { Router } from 'express'; -import { createEntry, getEntries, getEntryById, updateEntry, deleteEntry } from '../controllers/journal.controller'; +import { + createEntry, + getEntries, + getEntryById, + updateEntry, + deleteEntry, + getStats, +} from '../controllers/journal.controller'; import upload from '../services/upload.service'; -import { isAuth } from '../middleware/isAuth'; // Temporarily disabled for testing - +import { isAuth } from '../middleware/isAuth'; const router = Router(); -// todo - Add isAuth middleware once authentication is implemented -router.post('/journals',isAuth, upload.single('image'), createEntry); -router.get('/journals', isAuth, getEntries); -router.get('/journals/:id', isAuth, getEntryById); -router.patch('/journals/:id', isAuth, upload.single('image'), updateEntry); // Image upload for updates -router.delete('/journals/:id', isAuth, deleteEntry); +// All journal routes require authentication +router.use(isAuth); + +// ── /stats must come BEFORE /:id so it isn't captured as an ID param ───────── +router.get('/journals/stats', getStats); + +router.get('/journals', getEntries); +router.post('/journals', upload.single('image'), createEntry); + +router.get('/journals/:id', getEntryById); +router.patch('/journals/:id', upload.single('image'), updateEntry); +router.delete('/journals/:id', deleteEntry); export default router; \ No newline at end of file From cc5c695d791a76d9b5035c74dbc65080ce576430 Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:51 +0530 Subject: [PATCH 5/7] backend: update upload service --- .../backend/src/services/upload.service.ts | 105 +++++++++++++++--- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/packages/recovery-system/backend/src/services/upload.service.ts b/packages/recovery-system/backend/src/services/upload.service.ts index e019b70..4a88eb7 100644 --- a/packages/recovery-system/backend/src/services/upload.service.ts +++ b/packages/recovery-system/backend/src/services/upload.service.ts @@ -1,26 +1,99 @@ import { v2 as cloudinary } from 'cloudinary'; import { CloudinaryStorage } from 'multer-storage-cloudinary'; -import multer from 'multer'; +import multer, { StorageEngine } from 'multer'; +import path from 'path'; +import fs from 'fs'; import dotenv from 'dotenv'; dotenv.config(); -cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, -}); +// ── Cloudinary availability check ───────────────────────────────────────────── +const isCloudinaryConfigured = + !!process.env.CLOUDINARY_CLOUD_NAME && + !!process.env.CLOUDINARY_API_KEY && + !!process.env.CLOUDINARY_API_SECRET; + +// ── Backend base URL ───────────────────────────────────────────────────────── +// Used to build absolute image URLs when saving to local disk. +// Override with BACKEND_URL=https://your-api.com in production .env +const BACKEND_URL = ( + process.env.BACKEND_URL || `http://localhost:${process.env.PORT || 5000}` +).replace(/\/$/, ''); + +let storage: StorageEngine; -const storage = new CloudinaryStorage({ - cloudinary: cloudinary, - params: async (_req, file) => { - return { - folder: 'relife_app', +if (isCloudinaryConfigured) { + // ── Cloudinary storage ──────────────────────────────────────────────────── + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }); + + storage = new CloudinaryStorage({ + cloudinary, + params: async (_req, file) => ({ + folder: 'relife_app', allowed_formats: ['jpg', 'png', 'jpeg', 'webp'], - public_id: file.fieldname + '-' + Date.now(), - }; - }, + public_id: `${file.fieldname}-${Date.now()}`, + }), + }) as StorageEngine; + + console.log('📸 Upload service: Cloudinary'); +} else { + // ── Local disk fallback ─────────────────────────────────────────────────── + const uploadDir = path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; + cb(null, `${file.fieldname}-${Date.now()}${ext}`); + }, + }); + + console.log(`📸 Upload service: local disk → ${uploadDir}`); + console.log(` Serving images at: ${BACKEND_URL}/uploads/`); + console.log(' Add CLOUDINARY_* vars to .env to switch to cloud storage.'); +} + +// ── File filter ─────────────────────────────────────────────────────────────── +const fileFilter = ( + _req: Express.Request, + file: Express.Multer.File, + cb: multer.FileFilterCallback, +) => { + const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Only JPEG, PNG, WebP and GIF images are allowed')); + } +}; + +const upload = multer({ + storage, + fileFilter, + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max }); -const upload = multer({ storage: storage }); -export default upload; \ No newline at end of file +export default upload; + +/** + * Returns a publicly-accessible URL for the uploaded file. + * + * Cloudinary → file.path is already a full https:// CDN URL, return as-is. + * Local disk → file.path is an absolute filesystem path, convert to + * http://localhost:5000/uploads/ + * so the frontend (running on a different port) can fetch it. + */ +export function getFileUrl(file: Express.Multer.File): string { + if (isCloudinaryConfigured) { + return file.path; // Already a full Cloudinary URL + } + const filename = path.basename(file.path); + return `${BACKEND_URL}/uploads/${filename}`; +} \ No newline at end of file From d1b065caf420382d184f57e23357de98c3718871 Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:51 +0530 Subject: [PATCH 6/7] frontend: update next config --- .../recovery-system/frontend/next.config.js | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/recovery-system/frontend/next.config.js b/packages/recovery-system/frontend/next.config.js index e536db0..917be3e 100644 --- a/packages/recovery-system/frontend/next.config.js +++ b/packages/recovery-system/frontend/next.config.js @@ -1,20 +1,43 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - reactStrictMode: true, - transpilePackages: [], - typescript: { - // TypeScript errors will be shown during development - ignoreBuildErrors: false, + images: { + // Allow Next.js to load from the backend dev server and Cloudinary + remotePatterns: [ + { + protocol: 'http', + hostname: 'localhost', + port: '5000', + pathname: '/uploads/**', + }, + { + protocol: 'https', + hostname: '*.cloudinary.com', + pathname: '/**', + }, + { + // randomuser.me avatars used for counselor seed data + protocol: 'https', + hostname: 'randomuser.me', + pathname: '/**', + }, + ], }, + + // Proxy /uploads requests from Next.js dev server → backend + // This means also works (relative URL) async rewrites() { return [ { - // Proxy all /api/* requests to the backend during development + source: '/uploads/:path*', + destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/uploads/:path*`, + }, + // Proxy all /api calls to backend (if not already present) + { source: '/api/:path*', destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/:path*`, }, ]; }, -} +}; -module.exports = nextConfig +module.exports = nextConfig; \ No newline at end of file From 8048101367d09954f228008780ae9ef8b0a2945b Mon Sep 17 00:00:00 2001 From: Umesh Induranga Date: Sat, 21 Mar 2026 12:30:52 +0530 Subject: [PATCH 7/7] frontend: add journals page --- .../src/app/(dashboard)/journals/page.tsx | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 packages/recovery-system/frontend/src/app/(dashboard)/journals/page.tsx diff --git a/packages/recovery-system/frontend/src/app/(dashboard)/journals/page.tsx b/packages/recovery-system/frontend/src/app/(dashboard)/journals/page.tsx new file mode 100644 index 0000000..f1330a6 --- /dev/null +++ b/packages/recovery-system/frontend/src/app/(dashboard)/journals/page.tsx @@ -0,0 +1,719 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + PenLine, Search, Trash2, Edit3, Lock, Unlock, X, Check, + AlertCircle, BookOpen, Zap, Heart, TrendingUp, Filter, + Image as ImageIcon, Sparkles, Flame, FileText, + RefreshCw, ChevronRight, Smile, Frown, Meh, + Cloud, ArrowLeft, Calendar, SortAsc, SortDesc, +} from 'lucide-react'; + +const C = { + teal: '#40738E', tealDark:'#2d5a72', tealLight:'#CFE1E1', tealFaint:'#EBF4F4', + green: '#8CD092', greenDark:'#5fa86e', greenFaint:'#EAF7ED', + pageBg: '#F7FBFE', surface:'#FFFFFF', dark:'#1B2A3D', + ink: '#0f2420', inkMid:'#2d4a47', inkMuted:'#6b8a87', + border: '#DDE9E8', borderMid:'#C8DCDB', + danger: '#dc2626', dangerFaint:'#fef2f2', + warn: '#b45309', warnFaint:'#fef9e7', +}; + +const API_URL = typeof window !== 'undefined' ? '' : (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'); + +const MOOD_CFG: Record; label: string; color: string; bg: string; border: string; dot: string }> = { + great: { Icon: Smile, label: 'Great', color: '#16a34a', bg: '#dcfce7', border: '#86efac', dot: '#22c55e' }, + good: { Icon: TrendingUp, label: 'Good', color: C.teal, bg: C.tealFaint, border: C.tealLight, dot: C.teal }, + okay: { Icon: Meh, label: 'Okay', color: C.warn, bg: C.warnFaint, border: '#fde68a', dot: '#f59e0b' }, + struggling: { Icon: Cloud, label: 'Struggling', color: '#9a3412', bg: '#fff7ed', border: '#fdba74', dot: '#f97316' }, + relapsed: { Icon: Frown, label: 'Relapsed', color: C.danger, bg: C.dangerFaint,border: '#fca5a5', dot: C.danger }, +}; +const MOOD_OPTIONS = ['great', 'good', 'okay', 'struggling', 'relapsed']; + +const TRIGGER_SUGGESTIONS = [ + 'Stress','Anxiety','Social pressure','Loneliness','Boredom','Work pressure', + 'Family issues','Sleep deprivation','Financial stress','Relationship conflict', + 'FOMO','Physical pain','Negative thoughts', +]; +const COPING_SUGGESTIONS = [ + 'Deep breathing','Exercise','Called support person','Journaling','Meditation', + 'Walked away','Distraction technique','Used hotline','Attended meeting', + 'Read recovery material','Cold water','Music', +]; + +interface JournalEntry { + _id: string; user: string; content: string; mood: string; + triggers: string[]; copingStrategies: string[]; + image?: string; isPrivate: boolean; createdAt: string; updatedAt: string; +} +interface Pagination { page: number; limit: number; total: number; totalPages: number; } + +async function apiFetchEntries(page = 1, limit = 20, dateFilter?: string) { + const p = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (dateFilter) p.set('date', dateFilter); + const res = await fetch(`${API_URL}/api/journals?${p}`, { credentials: 'include' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).message || 'Failed to load entries'); + return res.json() as Promise<{ entries: JournalEntry[]; pagination: Pagination }>; +} +async function apiCreate(fd: FormData): Promise { + const res = await fetch(`${API_URL}/api/journals`, { method: 'POST', credentials: 'include', body: fd }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.message || 'Failed to create entry'); + return json; +} +async function apiUpdate(id: string, fd: FormData): Promise { + const res = await fetch(`${API_URL}/api/journals/${id}`, { method: 'PATCH', credentials: 'include', body: fd }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.message || 'Failed to update entry'); + return json; +} +async function apiDelete(id: string): Promise { + const res = await fetch(`${API_URL}/api/journals/${id}`, { method: 'DELETE', credentials: 'include' }); + if (!res.ok) throw new Error('Failed to delete entry'); +} + +const fmtDate = (d: string) => new Date(d).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric', year:'numeric' }); +const fmtTime = (d: string) => new Date(d).toLocaleTimeString('en-US', { hour:'2-digit', minute:'2-digit' }); +const fmtShort = (d: string) => new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric' }); +const fmtDay = (d: string) => new Date(d).toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric' }); +const wc = (s: string) => s.trim().split(/\s+/).filter(Boolean).length; + +function TagInput({ label, value, onChange, suggestions, placeholder, accentColor }: { + label: string; value: string[]; onChange: (v: string[]) => void; + suggestions: string[]; placeholder: string; accentColor: string; +}) { + const [inp, setInp] = useState(''); + const [open, setOpen] = useState(false); + const add = (t: string) => { const s = t.trim(); if (s && !value.includes(s)) onChange([...value, s]); setInp(''); setOpen(false); }; + + return ( +
+ + {value.length > 0 && ( +
+ {value.map(t => ( + + {t} + + + ))} +
+ )} +
+ setInp(e.target.value)} onFocus={() => setOpen(true)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (inp.trim()) add(inp); } }} + placeholder={placeholder} + style={{ width:'100%', padding:'9px 12px', borderRadius:10, border:`1.5px solid ${C.border}`, fontSize:13, color:C.ink, background:C.surface, outline:'none', boxSizing:'border-box' as const, fontFamily:'inherit' }} + onFocus={e => (e.target.style.borderColor = C.teal)} + onBlur={e => { e.target.style.borderColor = C.border; setTimeout(() => setOpen(false), 120); }} /> + {open && (suggestions.filter(s => s.toLowerCase().includes(inp.toLowerCase()) && !value.includes(s)).length > 0 || inp.trim()) && ( +
+ {suggestions.filter(s => s.toLowerCase().includes(inp.toLowerCase()) && !value.includes(s)).map(s => ( + + ))} + {inp.trim() && !suggestions.includes(inp.trim()) && ( + + )} +
+ )} +
+
+ ); +} + +function EntryEditor({ entry, onSave, onCancel, saving }: { + entry?: JournalEntry | null; onSave: (fd: FormData) => Promise; + onCancel: () => void; saving: boolean; +}) { + const [content, setContent] = useState(entry?.content ?? ''); + const [mood, setMood] = useState(entry?.mood ?? 'okay'); + const [triggers, setTriggers] = useState(entry?.triggers ?? []); + const [coping, setCoping] = useState(entry?.copingStrategies ?? []); + const [isPrivate, setPrivate] = useState(entry?.isPrivate ?? true); + const [imgFile, setImgFile] = useState(null); + const [imgPrev, setImgPrev] = useState(entry?.image ?? null); + const [error, setError] = useState(''); + const fileRef = useRef(null); + const words = wc(content); + const moodCfg = MOOD_CFG[mood]; + + const handleImg = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; if (!f) return; + if (f.size > 5 * 1024 * 1024) { setError('Image must be under 5 MB'); return; } + setImgFile(f); + const r = new FileReader(); r.onload = ev => setImgPrev(ev.target?.result as string); r.readAsDataURL(f); + }; + + const submit = async () => { + if (content.trim().length < 10) { setError('Write at least 10 characters'); return; } + setError(''); + const fd = new FormData(); + fd.append('content', content.trim()); fd.append('mood', mood); + fd.append('triggers', JSON.stringify(triggers)); fd.append('copingStrategies', JSON.stringify(coping)); + fd.append('isPrivate', String(isPrivate)); + if (imgFile) fd.append('image', imgFile); + try { await onSave(fd); } catch (e: any) { setError(e.message); } + }; + + const today = new Date().toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric', year:'numeric' }); + + return ( +
+ {/* Top bar */} +
+ +
+ {today} +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Two-column layout */} +
+ + {/* ── Left panel ── */} +
+ + {/* Mood */} +
+

How are you feeling?

+
+ {MOOD_OPTIONS.map(m => { + const cfg = MOOD_CFG[m]; const active = mood === m; + return ( + + ); + })} +
+
+ + {/* Triggers */} +
+ +
+ + {/* Coping */} +
+ +
+ + {/* Image */} +
+

Photo (optional · max 5 MB)

+ + {imgPrev ? ( +
+ preview + +
+ ) : ( + + )} +
+ + {/* Privacy */} +
+ + {isPrivate ? : } +
+

{isPrivate?'Private entry':'Visible to supporters'}

+

{isPrivate?'Only you can read this':'Support network can view'}

+
+
+
+ + {/* ── Right: textarea ── */} +
+
+
+ Your thoughts + 0?C.teal:C.inkMuted }}>{words} {words===1?'word':'words'} +
+