feat: implement authentication and core API endpoints - auth, podcasts, episodes

This commit is contained in:
Michael committed 2026-04-16 09:55:15 +02:00
1 parent 8df1161023
commit 8c00a1b0f5
13 files changed
+822 -1

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import { Request, Response } from 'express';
import { User } from '../models/User';
import { generateTokens } from '../utils/jwt';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
username: z.string().min(2, 'Username must be at least 2 characters'),
});
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password required'),
});
export const register = async (req: Request, res: Response) => {
try {
const { email, password, username } = registerSchema.parse(req.body);
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({ message: 'Email already registered' });
}
// Create new user
const user = new User({
email,
password,
username,
});
await user.save();
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user);
res.status(201).json({
message: 'User registered successfully',
user: user.toJSON(),
tokens: { accessToken, refreshToken },
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: 'Validation error',
errors: error.errors,
});
}
console.error('Register error:', error);
res.status(500).json({ message: 'Registration failed' });
}
};
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = loginSchema.parse(req.body);
// Find user
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid email or password' });
}
// Verify password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return res.status(401).json({ message: 'Invalid email or password' });
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user);
res.json({
message: 'Login successful',
user: user.toJSON(),
tokens: { accessToken, refreshToken },
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: 'Validation error',
errors: error.errors,
});
}
console.error('Login error:', error);
res.status(500).json({ message: 'Login failed' });
}
};
export const getMe = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json({ user: user.toJSON() });
} catch (error) {
console.error('GetMe error:', error);
res.status(500).json({ message: 'Failed to fetch user' });
}
};
export const logout = async (req: Request, res: Response) => {
try {
// In a stateless JWT system, logout is client-side (delete token)
// Could implement token blacklist with Redis if needed
res.json({ message: 'Logout successful' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ message: 'Logout failed' });
}
};
@@ -0,0 +1,146 @@
import { Request, Response } from 'express';
import { Episode } from '../models/Episode';
import { UserProgress } from '../models/UserProgress';
import { UserSubscription } from '../models/UserSubscription';
export const getLatestEpisodes = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { limit = 30, skip = 0 } = req.query;
// Get user's subscribed podcasts
const subscriptions = await UserSubscription.find({
userId: req.user.id,
}).select('podcastId');
const podcastIds = subscriptions.map((sub) => sub.podcastId);
if (podcastIds.length === 0) {
return res.json({ episodes: [], count: 0 });
}
// Get latest episodes from subscribed podcasts
const episodes = await Episode.find({
podcastId: { $in: podcastIds },
})
.populate('podcastId', 'title author imageUrl')
.sort({ pubDate: -1 })
.limit(Number(limit))
.skip(Number(skip));
res.json({ episodes, count: episodes.length });
} catch (error) {
console.error('Get latest episodes error:', error);
res.status(500).json({ message: 'Failed to fetch episodes' });
}
};
export const getPodcastEpisodes = async (req: Request, res: Response) => {
try {
const { podcastId } = req.params;
const { limit = 50, skip = 0 } = req.query;
const episodes = await Episode.find({ podcastId })
.sort({ pubDate: -1 })
.limit(Number(limit))
.skip(Number(skip));
res.json({ episodes, count: episodes.length });
} catch (error) {
console.error('Get podcast episodes error:', error);
res.status(500).json({ message: 'Failed to fetch episodes' });
}
};
export const getEpisode = async (req: Request, res: Response) => {
try {
const { episodeId } = req.params;
const episode = await Episode.findById(episodeId).populate(
'podcastId',
'title author description imageUrl'
);
if (!episode) {
return res.status(404).json({ message: 'Episode not found' });
}
res.json({ episode });
} catch (error) {
console.error('Get episode error:', error);
res.status(500).json({ message: 'Failed to fetch episode' });
}
};
export const saveProgress = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { episodeId, position, isCompleted } = req.body;
if (!episodeId || position === undefined) {
return res.status(400).json({ message: 'Episode ID and position required' });
}
// Find or create progress record
let progress = await UserProgress.findOne({
userId: req.user.id,
episodeId,
});
if (progress) {
progress.position = position;
if (isCompleted !== undefined) {
progress.isCompleted = isCompleted;
}
progress.lastListenedAt = new Date();
} else {
progress = new UserProgress({
userId: req.user.id,
episodeId,
position,
isCompleted: isCompleted || false,
lastListenedAt: new Date(),
});
}
await progress.save();
res.json({
message: 'Progress saved',
progress,
});
} catch (error) {
console.error('Save progress error:', error);
res.status(500).json({ message: 'Failed to save progress' });
}
};
export const getProgress = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { episodeId } = req.params;
const progress = await UserProgress.findOne({
userId: req.user.id,
episodeId,
});
if (!progress) {
return res.json({ progress: null, message: 'No progress found' });
}
res.json({ progress });
} catch (error) {
console.error('Get progress error:', error);
res.status(500).json({ message: 'Failed to fetch progress' });
}
};
@@ -0,0 +1,147 @@
import { Request, Response } from 'express';
import { Podcast } from '../models/Podcast';
import { UserSubscription } from '../models/UserSubscription';
import { Episode } from '../models/Episode';
import { z } from 'zod';
const subscribeSchema = z.object({
rssUrl: z.string().url('Invalid RSS URL'),
});
export const getUserSubscriptions = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const subscriptions = await UserSubscription.find({
userId: req.user.id,
})
.populate('podcastId')
.sort({ subscribedAt: -1 });
const podcasts = subscriptions.map((sub) => sub.podcastId);
res.json({ podcasts, count: podcasts.length });
} catch (error) {
console.error('Get subscriptions error:', error);
res.status(500).json({ message: 'Failed to fetch subscriptions' });
}
};
export const subscribe = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { rssUrl } = subscribeSchema.parse(req.body);
// Check if podcast exists
let podcast = await Podcast.findOne({ rssUrl });
if (!podcast) {
// For now, create a basic podcast entry
// In production, you'd fetch from PodcastIndex API or parse the RSS feed
return res.status(400).json({
message: 'Podcast not found. Please use a valid RSS URL.',
});
}
// Check if already subscribed
const existingSubscription = await UserSubscription.findOne({
userId: req.user.id,
podcastId: podcast._id,
});
if (existingSubscription) {
return res.status(409).json({ message: 'Already subscribed to this podcast' });
}
// Create subscription
const subscription = new UserSubscription({
userId: req.user.id,
podcastId: podcast._id,
});
await subscription.save();
res.status(201).json({
message: 'Subscribed successfully',
podcast: podcast.toJSON(),
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: 'Validation error',
errors: error.errors,
});
}
console.error('Subscribe error:', error);
res.status(500).json({ message: 'Failed to subscribe' });
}
};
export const unsubscribe = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { podcastId } = req.params;
const subscription = await UserSubscription.findOneAndDelete({
userId: req.user.id,
podcastId,
});
if (!subscription) {
return res.status(404).json({ message: 'Subscription not found' });
}
res.json({ message: 'Unsubscribed successfully' });
} catch (error) {
console.error('Unsubscribe error:', error);
res.status(500).json({ message: 'Failed to unsubscribe' });
}
};
export const searchPodcasts = async (req: Request, res: Response) => {
try {
const { q, limit = 20, skip = 0 } = req.query;
if (!q) {
return res.status(400).json({ message: 'Search query required' });
}
const podcasts = await Podcast.find(
{ $text: { $search: q as string } },
{ score: { $meta: 'textScore' } }
)
.sort({ score: { $meta: 'textScore' } })
.limit(Number(limit))
.skip(Number(skip));
res.json({ podcasts, count: podcasts.length });
} catch (error) {
console.error('Search error:', error);
res.status(500).json({ message: 'Search failed' });
}
};
export const getPodcast = async (req: Request, res: Response) => {
try {
const { podcastId } = req.params;
const podcast = await Podcast.findById(podcastId);
if (!podcast) {
return res.status(404).json({ message: 'Podcast not found' });
}
res.json({ podcast });
} catch (error) {
console.error('Get podcast error:', error);
res.status(500).json({ message: 'Failed to fetch podcast' });
}
};
+8 -1
View File
@@ -5,6 +5,9 @@ import { connectDB } from './config/database';
import { initRedis, closeRedis } from './config/redis';
import { authenticate, optional } from './middleware/auth';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import authRoutes from './routes/authRoutes';
import podcastRoutes from './routes/podcastRoutes';
import episodeRoutes from './routes/episodeRoutes';
const app = express();
const PORT = process.env.PORT || 5000;
@@ -36,11 +39,15 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes - TODO: Import and register routes
// API Routes
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'API is running' });
});
app.use('/api/auth', authRoutes);
app.use('/api/podcasts', podcastRoutes);
app.use('/api/episodes', episodeRoutes);
// 404 handler
app.use(notFoundHandler);
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { register, login, getMe, logout } from '../controllers/authController';
import { authenticate } from '../middleware/auth';
const router = Router();
// Public routes
router.post('/register', register);
router.post('/login', login);
// Protected routes
router.get('/me', authenticate, getMe);
router.post('/logout', authenticate, logout);
export default router;
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express';
import {
getLatestEpisodes,
getPodcastEpisodes,
getEpisode,
saveProgress,
getProgress,
} from '../controllers/episodeController';
import { authenticate } from '../middleware/auth';
const router = Router();
// Protected routes
router.get('/latest', authenticate, getLatestEpisodes);
router.post('/progress', authenticate, saveProgress);
router.get('/progress/:episodeId', authenticate, getProgress);
// Public routes
router.get('/:episodeId', getEpisode);
router.get('/podcast/:podcastId', getPodcastEpisodes);
export default router;
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express';
import {
getUserSubscriptions,
subscribe,
unsubscribe,
searchPodcasts,
getPodcast,
} from '../controllers/podcastController';
import { authenticate } from '../middleware/auth';
const router = Router();
// Protected routes
router.get('/subscriptions', authenticate, getUserSubscriptions);
router.post('/subscribe', authenticate, subscribe);
router.delete('/:podcastId/unsubscribe', authenticate, unsubscribe);
// Public routes
router.get('/search', searchPodcasts);
router.get('/:podcastId', getPodcast);
export default router;