Skip to content
Open
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
35 changes: 31 additions & 4 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const signUpSchema = z.object({
})

const signInSchema = z.object({
email: z.string().email(),
emailOrUsername: z.string().min(1),
password: z.string().min(1)
})

Expand Down Expand Up @@ -93,23 +93,48 @@ router.post('/signup', asyncHandler(async (req, res) => {

/**
* POST /api/auth/signin
* Sign in with email and password
* Sign in with email/username and password
*/
router.post('/signin', asyncHandler(async (req, res) => {
const validationResult = signInSchema.safeParse(req.body)
if (!validationResult.success) {
throw createError(400, 'Invalid request data', 'VALIDATION_ERROR')
}

const { email, password } = validationResult.data
const { emailOrUsername, password } = validationResult.data

let email = emailOrUsername

// If input doesn't contain @, treat it as username and look up email
if (!emailOrUsername.includes('@')) {
console.log(`πŸ” Looking up email for username: ${emailOrUsername}`)
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('email')
.eq('username', emailOrUsername.toLowerCase())
.single()

if (profileError || !profile) {
console.log(`❌ Username not found: ${emailOrUsername}`)
throw createError(401, 'Invalid username or password')
}

email = profile.email
console.log(`βœ… Found email for username: ${emailOrUsername}`)
}

console.log(`πŸ” Attempting to sign in with email: ${email}`)
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})

if (error) {
throw createError(401, 'Invalid email or password')
console.log(`❌ Sign in failed:`, error.message)
if (error.message.includes('Invalid login credentials')) {
throw createError(401, 'Invalid email/username or password')
}
throw createError(401, error.message)
}

if (!data.user || !data.session) {
Expand All @@ -127,6 +152,8 @@ router.post('/signin', asyncHandler(async (req, res) => {
throw createError(500, 'Failed to fetch user profile')
}

console.log(`βœ… Sign in successful for user: ${profile.username}`)

res.json({
message: 'Signed in successfully',
user: data.user,
Expand Down
135 changes: 135 additions & 0 deletions backend/src/routes/friends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,141 @@ const searchFriendsSchema = z.object({
limit: z.number().min(1).max(20).optional().default(10)
})

/**
* GET /api/friends/suggestions
* Get friend suggestions based on habits, location, and occupation
*/
router.get('/suggestions', asyncHandler(async (req: AuthenticatedRequest, res) => {
if (!req.user) {
throw createError(401, 'Authentication required')
}

const limit = parseInt(req.query.limit as string) || 10

console.log('πŸ” Finding friend suggestions for user:', req.user.id)

// Get current user's profile
const { data: currentProfile, error: profileError } = await supabase
.from('profiles')
.select('occupation, location')
.eq('id', req.user.id)
.single()

if (profileError) {
throw createError(500, 'Failed to fetch user profile')
}

// Get current user's habits
const { data: userHabits } = await supabase
.from('habits')
.select('category')
.eq('user_id', req.user.id)
.eq('is_active', true)

const userHabitCategories = userHabits?.map(h => h.category) || []

// Get existing friendships to exclude them
const { data: existingFriendships } = await supabase
.from('friendships')
.select('requester_id, addressee_id')
.or(`requester_id.eq.${req.user.id},addressee_id.eq.${req.user.id}`)

const excludeIds = [req.user.id]
existingFriendships?.forEach(f => {
const friendId = f.requester_id === req.user!.id ? f.addressee_id : f.requester_id
excludeIds.push(friendId)
})

// Find users with similar attributes
let query = supabase
.from('profiles')
.select('id, username, display_name, avatar_url, snap_score, current_streak, occupation, location')
.not('id', 'in', `(${excludeIds.join(',')})`)
.eq('ghost_mode', false)

const suggestions: any[] = []

// First, try to find users with matching occupation or location
if (currentProfile.occupation || currentProfile.location) {
let orConditions = []
if (currentProfile.occupation) {
orConditions.push(`occupation.eq.${currentProfile.occupation}`)
}
if (currentProfile.location) {
orConditions.push(`location.ilike.%${currentProfile.location}%`)
}

const { data: matchingUsers } = await query
.or(orConditions.join(','))
.order('snap_score', { ascending: false })
.limit(limit * 2)

if (matchingUsers) {
suggestions.push(...matchingUsers)
}
}

// Then find users with similar habits
if (userHabitCategories.length > 0) {
const { data: habitsData } = await supabase
.from('habits')
.select('user_id, category')
.in('category', userHabitCategories)
.eq('is_active', true)
.not('user_id', 'in', `(${excludeIds.join(',')})`)

if (habitsData) {
// Count matching habits per user
const habitMatches: Record<string, number> = {}
habitsData.forEach(h => {
habitMatches[h.user_id] = (habitMatches[h.user_id] || 0) + 1
})

// Get top matching users
const topMatchUserIds = Object.entries(habitMatches)
.sort(([, a], [, b]) => b - a)
.slice(0, limit)
.map(([userId]) => userId)

if (topMatchUserIds.length > 0) {
const { data: habitMatchUsers } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, snap_score, current_streak, occupation, location')
.in('id', topMatchUserIds)
.eq('ghost_mode', false)

if (habitMatchUsers) {
suggestions.push(...habitMatchUsers)
}
}
}
}

// Remove duplicates and limit results
const uniqueSuggestions = Array.from(
new Map(suggestions.map(s => [s.id, s])).values()
).slice(0, limit)

console.log('βœ… Found suggestions:', uniqueSuggestions.length)

res.json({
suggestions: uniqueSuggestions.map(user => ({
id: user.id,
username: user.username,
displayName: user.display_name,
avatarUrl: user.avatar_url,
snapScore: user.snap_score,
currentStreak: user.current_streak,
occupation: user.occupation,
location: user.location,
matchReason: currentProfile.occupation === user.occupation ? 'Same occupation' :
currentProfile.location && user.location?.includes(currentProfile.location) ? 'Same location' :
'Similar habits'
})),
total: uniqueSuggestions.length
})
}))

/**
* GET /api/friends/search
* Search for users to add as friends
Expand Down
27 changes: 27 additions & 0 deletions backend/src/routes/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ const updateProfileSchema = z.object({
locationEnabled: z.boolean().optional()
})

/**
* GET /api/profiles/username-available/:username
* Check if username is available (PUBLIC endpoint - no auth required)
*/
router.get('/username-available/:username', asyncHandler(async (req: express.Request, res) => {
const { username } = req.params

if (!username || username.length < 3) {
return res.json({ available: false, message: 'Username must be at least 3 characters' })
}

const { data: existingProfile, error } = await supabase
.from('profiles')
.select('id')
.eq('username', username.toLowerCase())
.maybeSingle()

if (error) {
throw createError(500, 'Failed to check username availability')
}

res.json({
available: !existingProfile,
message: existingProfile ? 'Username is already taken' : 'Username is available'
})
}))

/**
* GET /api/profiles/me
* Get current user's profile
Expand Down
54 changes: 54 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,57 @@ app.use('/uploads', express.static('uploads'))
// Rate limiting
app.use(rateLimiter)

// Root endpoint - API documentation
app.get('/', (req, res) => {
res.json({
name: 'Snapbit API',
version: '1.0.0',
description: 'Habit tracking with friends - Backend API',
status: 'running',
timestamp: new Date().toISOString(),
endpoints: {
health: 'GET /health',
auth: {
signup: 'POST /api/auth/signup',
login: 'POST /api/auth/login',
logout: 'POST /api/auth/logout'
},
profiles: {
me: 'GET /api/profiles/me',
update: 'PUT /api/profiles/me',
byUsername: 'GET /api/profiles/:username'
},
habits: {
list: 'GET /api/habits',
create: 'POST /api/habits',
update: 'PUT /api/habits/:id',
delete: 'DELETE /api/habits/:id'
},
snaps: {
create: 'POST /api/snaps',
list: 'GET /api/snaps',
verify: 'POST /api/snaps/:id/verify'
},
friends: {
list: 'GET /api/friends',
request: 'POST /api/friends/request',
accept: 'PUT /api/friends/:id/accept',
streaks: 'GET /api/friends/streaks'
},
chat: {
rooms: 'GET /api/chat/rooms',
messages: 'GET /api/chat/:roomId/messages',
send: 'POST /api/chat/:roomId/messages'
},
leaderboard: 'GET /api/leaderboard',
snapFeed: 'GET /api/snap-feed',
streaks: 'GET /api/streaks'
},
websocket: 'ws://localhost:' + PORT,
docs: 'https://github.com/yashudeveloper/Snapbit'
})
})

// Health check endpoint
app.get('/health', (req, res) => {
res.json({
Expand All @@ -76,6 +127,9 @@ app.get('/health', (req, res) => {

// API routes
app.use('/api/auth', authRoutes)
// Public profile routes (username availability check)
app.get('/api/profiles/username-available/:username', profileRoutes)
// Protected profile routes
app.use('/api/profiles', authMiddleware, profileRoutes)
app.use('/api/habits', authMiddleware, habitRoutes)
app.use('/api/snaps', authMiddleware, snapRoutes)
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from 'react'

interface ToastProps {
message: string
type: 'success' | 'error' | 'info'
onClose: () => void
duration?: number
}

export default function Toast({ message, type, onClose, duration = 4000 }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration)
return () => clearTimeout(timer)
}, [onClose, duration])

const bgColor = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500'
}[type]

const icon = {
success: 'βœ…',
error: '❌',
info: 'ℹ️'
}[type]

return (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-[9999] animate-slide-down px-4">
<div className={`${bgColor} text-white px-4 sm:px-6 py-3 sm:py-4 rounded-xl sm:rounded-2xl shadow-2xl flex items-center gap-2 sm:gap-3 min-w-[280px] sm:min-w-[320px] max-w-[90vw] sm:max-w-[500px]`}>
<span className="text-xl sm:text-2xl flex-shrink-0">{icon}</span>
<p className="flex-1 font-medium text-sm sm:text-base">{message}</p>
<button
onClick={onClose}
className="text-white hover:text-gray-200 text-2xl font-bold flex-shrink-0 w-6 h-6 flex items-center justify-center"
aria-label="Close notification"
>
Γ—
</button>
</div>
</div>
)
}
Loading