Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:

jobs:
build-and-test:
test-and-build:
runs-on: ubuntu-latest

services:
Expand All @@ -30,7 +30,7 @@ jobs:
npm ci --prefix client

- name: Run tests
run: npm test
run: NODE_OPTIONS="--experimental-vm-modules" npm test
env:
MONGO_URI: mongodb://localhost:27017/test_db
JWT_SECRET: ${{ secrets.JWT_SECRET }}
Expand All @@ -41,6 +41,7 @@ jobs:
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
BCRYPT_SALT_ROUNDS: 1

- name: Build (Backend & Frontend)
run: npm run build
63 changes: 57 additions & 6 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,63 @@ on:
workflow_dispatch:

jobs:
deploy:
run-tests:
runs-on: self-hosted

steps:
- uses: actions/checkout@v4

- name: Set up Environment
run: |
echo "PORT=${{ vars.PORT || 3000 }}" > .env
echo "NODE_ENV=test" >> .env
echo "MONGO_URI=mongodb://admin:market_watch_pass@127.0.0.1:27017/test_db?authSource=admin" >> .env
echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
echo "REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }}" >> .env
echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" >> .env
echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env
echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env
echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env

- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22

- name: Run Tests on Host
run: |
npm install
docker compose up -d mongodb
sleep 15
MONGO_URI=mongodb://admin:market_watch_pass@127.0.0.1:27017/test_db?authSource=admin \
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} \
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }} \
JWT_SECRET=${{ secrets.JWT_SECRET }} \
REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} \
NODE_OPTIONS=--experimental-vm-modules \
BCRYPT_SALT_ROUNDS=1 npm test
docker compose stop mongodb

- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: test-report.html
retention-days: 1

deploy-app:
needs: run-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4

- name: Download Test Report
uses: actions/download-artifact@v4
with:
name: test-report
path: .

- name: Set up Production Environment
run: |
echo "PORT=${{ vars.PORT || 3000 }}" > .env
echo "NODE_ENV=${{ vars.NODE_ENV || 'production' }}" >> .env
Expand All @@ -27,9 +77,10 @@ jobs:
echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env
echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env


- name: Build and Restart with Docker Compose
- name: Build and Restart App Container
run: |
docker-compose down
docker-compose up --build -d
docker compose down
docker compose up --build -d
docker system prune -f
sleep 10 # Wait for container to fully start
docker exec market_watch_app sh -c "BCRYPT_SALT_ROUNDS=1 npm test"
31 changes: 22 additions & 9 deletions .github/workflows/sync-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ on:
branches: [ "production" ]
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
sync-to-main:
runs-on: ubuntu-latest
# ONLY run if the push to production did NOT come from main
# This avoids the "Main -> Prod -> Main" circular loop
if: github.event.head_commit.author.name != 'github-actions' && !contains(github.event.head_commit.message, 'main')

steps:
Expand All @@ -18,15 +21,25 @@ jobs:
fetch-depth: 0
token: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }}

- name: Sync changes to main
- name: Create PR to sync production back to main
env:
GH_TOKEN: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }}
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git fetch origin main production

git checkout main
git fetch origin production
# Check if there are differences
if git diff --quiet origin/main..origin/production; then
echo "No differences between production and main. Skipping."
exit 0
fi

# Use allow-unrelated-histories to bridge the gap between branches if they diverged
git merge origin/production --allow-unrelated-histories -m "🤖 Sync hotfix from production to main"
# Create a sync branch from production
git checkout -b sync/prod-to-main origin/production

git push origin main
# Create PR using GitHub CLI
gh pr create \
--base main \
--head sync/prod-to-main \
--title "🤖 Sync: Production hotfixes back to main" \
--body "Automated PR to sync hotfix changes from production back to main." \
|| echo "PR already exists or nothing to sync."
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ PAGINATION_COMPLETE.md
CLEANUP_SUMMARY.md

# PDF documents (specs, mockups — local only)
*.pdf
# Test reports
test-report.html
7 changes: 2 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,12 @@ RUN npm install --omit=dev
# Copy compiled backend
COPY --from=build /app/dist ./dist

# Copy test report (generated during build test step if added to workflow, or copied if exists)
COPY --from=build /app/test-report.html ./test-report.html
# Copy test report (generated by GitHub Actions Host Runner)
COPY ./test-report.html ./test-report.html

# Copy compiled frontend
COPY --from=build /app/client/dist ./client/dist

# Copy test report (if generated)
COPY --from=build /app/test-report.html ./test-report.html

# Copy other necessary files (like uploads or assets)
COPY src/uploads ./src/uploads

Expand Down
Binary file added ai_test_err.txt
Binary file not shown.
Binary file added all_tests_out.txt
Binary file not shown.
81 changes: 81 additions & 0 deletions client/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState, useEffect } from 'react';
import { Image } from 'react-bootstrap';
import { User } from 'lucide-react';
import { getImageUrl } from '../services/api';

interface AvatarProps {
src?: string | null;
username?: string;
size?: number;
className?: string;
border?: boolean;
}

const Avatar = ({ src, username, size = 40, className = '', border = false }: AvatarProps) => {
const [error, setError] = useState(false);

// Reset error when source changes
useEffect(() => {
setError(false);
}, [src]);

// Reset error if src changes
const hasImage = src &&
src !== 'null' &&
src !== 'undefined' &&
src !== '' &&
!src.includes('undefined') &&
!src.includes('null');

if (hasImage && !error) {
return (
<div
className={`rounded-circle overflow-hidden d-flex align-items-center justify-content-center ${className} ${border ? 'border' : ''}`}
style={{ width: `${size}px`, height: `${size}px` }}
>
<Image
src={getImageUrl(src)}
width={size}
height={size}
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
onError={() => setError(true)}
alt={username || 'User avatar'}
/>
</div>
);
}

// Fallback: Initials or Icon with premium dynamic colors
const initials = username ? username[0].toUpperCase() : null;

// Generate a consistent color based on username
const colors = [
{ bg: 'rgba(13, 110, 253, 0.1)', text: '#0d6efd' }, // Blue
{ bg: 'rgba(102, 16, 242, 0.1)', text: '#6610f2' }, // Indigo
{ bg: 'rgba(111, 66, 193, 0.1)', text: '#6f42c1' }, // Purple
{ bg: 'rgba(214, 51, 132, 0.1)', text: '#d63384' }, // Pink
{ bg: 'rgba(253, 126, 20, 0.1)', text: '#fd7e14' }, // Orange
{ bg: 'rgba(25, 135, 84, 0.1)', text: '#198754' } // Green
];

const colorIndex = username ? username.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length : 0;
const selectedColor = colors[colorIndex];

return (
<div
className={`rounded-circle d-flex align-items-center justify-content-center ${className} ${border ? 'border' : ''}`}
style={{
width: `${size}px`,
height: `${size}px`,
backgroundColor: initials ? selectedColor.bg : '#f2f2f2',
color: initials ? selectedColor.text : '#6c757d',
fontSize: `${size * 0.4}px`,
fontWeight: 600
}}
>
{initials ? initials : <User size={size * 0.6} />}
</div>
);
};

export default Avatar;
34 changes: 4 additions & 30 deletions client/src/components/CommentSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useState, useEffect, type FormEvent } from 'react';
import { Card, Button, Form, Image, Spinner, Dropdown } from 'react-bootstrap';
import { Card, Button, Form, Spinner, Dropdown } from 'react-bootstrap';
import { useAuth } from '../context/AuthContext';
import { getComments, createComment, deleteComment, updateComment, type Comment } from '../services/commentService';
import { Send, MoreVertical, Trash2, Edit2, X, Check } from 'lucide-react';
import { getImageUrl } from '../services/api';
import Avatar from './Avatar';

interface CommentSectionProps {
postId: string;
Expand Down Expand Up @@ -104,20 +104,7 @@ const CommentSection = ({ postId }: CommentSectionProps) => {
<Card className="border-0 shadow-sm mb-4 rounded-4">
<Card.Body className="p-3">
<Form onSubmit={handleSubmit} className="d-flex gap-2">
{user?.image ? (
<Image
src={getImageUrl(user.image)}
roundedCircle
width={32}
height={32}
className="border flex-shrink-0"
style={{ objectFit: 'cover' }}
/>
) : (
<div className="bg-light rounded-circle d-flex align-items-center justify-content-center border flex-shrink-0" style={{ width: '32px', height: '32px' }}>
<span className="text-muted small">{user?.username?.[0] || 'U'}</span>
</div>
)}
<Avatar src={user?.image} username={user?.username} size={32} border />
<Form.Control
type="text"
placeholder="Write a comment..."
Expand Down Expand Up @@ -149,20 +136,7 @@ const CommentSection = ({ postId }: CommentSectionProps) => {
<div className="d-flex flex-column gap-3">
{comments.map(comment => (
<div key={comment._id} className="d-flex gap-2">
{comment.owner?.image ? (
<Image
src={getImageUrl(comment.owner.image)}
roundedCircle
width={32}
height={32}
className="border flex-shrink-0 mt-1"
style={{ objectFit: 'cover' }}
/>
) : (
<div className="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center border flex-shrink-0 mt-1" style={{ width: '32px', height: '32px' }}>
<span className="text-primary fw-bold small">{comment.owner?.username?.[0]?.toUpperCase() || 'U'}</span>
</div>
)}
<Avatar src={comment.owner?.image} username={comment.owner?.username} size={32} className="mt-1" border />
<div className="flex-grow-1">
<div className="bg-white p-3 rounded-4 shadow-sm position-relative group">
<div className="d-flex justify-content-between align-items-start mb-1">
Expand Down
52 changes: 14 additions & 38 deletions client/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Container, Navbar, Nav, Dropdown, Image } from 'react-bootstrap';
import { LogOut, Search, PlusCircle, Home, TrendingUp, User, Bell } from 'lucide-react';
import { getImageUrl } from '../services/api';
import { Container, Navbar, Nav, Dropdown } from 'react-bootstrap';
import { Bell, Home, LogOut, PlusCircle, Search, TrendingUp } from 'lucide-react';

const ProfileAvatar = ({ user }: { user: any }) => {
const [imageError, setImageError] = useState(false);

// Check if the image string is valid
const hasValidImageStr = user?.image && user.image !== 'null' && user.image !== 'undefined';

if (hasValidImageStr && !imageError) {
return (
<Image
src={getImageUrl(user.image)}
roundedCircle
width={40}
height={40}
className="border"
style={{ objectFit: 'cover' }}
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
);
}

// Fallback UI
return (
<div className="bg-light rounded-circle d-flex align-items-center justify-content-center border" style={{ width: '40px', height: '40px' }}>
<User size={24} className="text-muted" />
</div>
);
};
import Avatar from './Avatar';

const Layout = () => {
const { user, logout } = useAuth();
Expand Down Expand Up @@ -85,18 +56,23 @@ const Layout = () => {
</Link>
</Nav>
)}
<Nav className="d-flex flex-row align-items-center justify-content-center gap-3 mt-3 mt-lg-0">
<Nav className="ms-auto d-flex align-items-center flex-nowrap gap-2 mt-3 mt-lg-0">
<Link
to="/create-post"
className="btn btn-light d-flex align-items-center justify-content-center p-2 rounded-circle border-0"
style={{ width: '40px', height: '40px', background: 'rgba(255,255,255,0.2)' }}
className="nav-link p-1 rounded-circle d-flex align-items-center justify-content-center border-0 hover-bg"
style={{ width: '40px', height: '40px' }}
title="Add Post"
>
<PlusCircle size={20} className="text-dark" />
<PlusCircle size={22} className="text-muted" />
</Link>

<Dropdown align="end">
<Dropdown.Toggle variant="transparent" className="p-0 border-0 no-arrow after-none">
<ProfileAvatar user={user} />
<Dropdown.Toggle
variant="link"
className="p-0 border-0 shadow-none text-decoration-none dropdown-toggle-no-caret d-flex align-items-center"
id="profile-dropdown"
>
<Avatar src={user?.image} username={user?.username} size={40} border />
</Dropdown.Toggle>

<Dropdown.Menu className="shadow border-0 rounded-4 mt-2 p-2">
Expand Down
Loading
Loading