feat: implement backend podcast subscription controllers and frontend library page with optimistic updates

This commit is contained in:
Michael committed 2026-04-16 13:07:46 +02:00
1 parent 38521c45dc
commit 872ef5c31a
5 files changed
+92 -25

No files matched your search

+9 -5
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import mongoose from 'mongoose';
import { Podcast } from '../models/Podcast';
import { UserSubscription } from '../models/UserSubscription';
import { Episode } from '../models/Episode';
@@ -106,15 +107,20 @@ export const unsubscribe = async (req: Request, res: Response) => {
const { podcastId } = req.params;
// Log for debugging on the server
console.log(`[Unsubscribe] User ${req.user.id} attempting to unsubscribe from podcast ${podcastId}`);
const subscription = await UserSubscription.findOneAndDelete({
userId: req.user.id,
podcastId,
userId: new mongoose.Types.ObjectId(req.user.id),
podcastId: new mongoose.Types.ObjectId(podcastId),
});
if (!subscription) {
console.warn(`[Unsubscribe] No subscription found for user ${req.user.id} and podcast ${podcastId}`);
return res.status(404).json({ message: 'Subscription not found' });
}
console.log(`[Unsubscribe] Successfully removed subscription for user ${req.user.id}`);
res.json({ message: 'Unsubscribed successfully' });
} catch (error) {
console.error('Unsubscribe error:', error);
@@ -198,12 +204,10 @@ export const subscribeFromDiscovery = async (req: Request, res: Response) => {
try {
const result = await rssParserService.createPodcastFromRss(rssUrl, {
title: title || 'Unknown Podcast',
description: '',
author: author || '',
imageUrl: imageUrl,
episodes: [],
});
// Refetch as a proper Mongoose document (result.podcast is a plain object)
// Refetch as a proper Mongoose document
podcast = await Podcast.findById(result.podcast._id);
} catch (error: any) {
if (error.message.includes('already in database')) {
+10
View File
@@ -10,6 +10,8 @@ export interface IPodcast extends Document {
language: string;
lastFetched: Date;
episodeCount: number;
lastEpisodeTitle?: string;
lastEpisodeDate?: Date;
createdAt: Date;
updatedAt: Date;
}
@@ -55,6 +57,14 @@ const podcastSchema = new Schema<IPodcast>(
type: Number,
default: 0,
},
lastEpisodeTitle: {
type: String,
default: '',
},
lastEpisodeDate: {
type: Date,
default: null,
},
},
{
timestamps: true,
+16 -3
View File
@@ -195,6 +195,12 @@ export const rssParserService = {
// Update podcast metadata
podcast.lastFetched = new Date();
podcast.episodeCount = await Episode.countDocuments({ podcastId });
if (episodes.length > 0) {
podcast.lastEpisodeTitle = episodes[0].title;
podcast.lastEpisodeDate = episodes[0].pubDate;
}
await podcast.save();
const result = {
@@ -231,9 +237,10 @@ export const rssParserService = {
throw new Error('Podcast already in database');
}
// Parse feed if not provided
if (!feedData) {
feedData = await rssParserService.parseFeed(rssUrl);
// Always parse feed to get fresh description and episodes, even if partial data provided
if (!feedData || !feedData.description || !feedData.episodes || feedData.episodes.length === 0) {
const freshData = await rssParserService.parseFeed(rssUrl);
feedData = { ...feedData, ...freshData };
}
// Create podcast
@@ -275,6 +282,12 @@ export const rssParserService = {
podcast.episodeCount = await Episode.countDocuments({ podcastId: podcast._id });
podcast.lastFetched = new Date();
if (episodes.length > 0) {
podcast.lastEpisodeTitle = episodes[0].title;
podcast.lastEpisodeDate = episodes[0].pubDate;
}
await podcast.save();
return {
+55 -17
View File
@@ -39,9 +39,10 @@ export default function Library() {
queryClient.setQueryData(
['podcasts', 'subscriptions'],
(old: any) => {
if (!old) return old;
const filtered = old.podcasts.filter((p: any) => p._id !== podcastId);
return { podcasts: filtered, count: filtered.length };
if (!old || !old.podcasts) return old;
// Ensure we compare strings to avoid type issues
const filtered = old.podcasts.filter((p: any) => String(p._id) !== String(podcastId));
return { ...old, podcasts: filtered, count: filtered.length };
}
);
@@ -52,10 +53,12 @@ export default function Library() {
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any, podcastId, context) => {
onError: (error: any) => {
// Rollback: re-fetch the real data from server if the mutation failed
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
alert(`Erreur : ${error.response?.data?.message || 'Impossible de se désabonner'}`);
const errorMessage = error.response?.data?.message || 'Erreur inconnue';
alert(`Erreur lors du désabonnement : ${errorMessage}`);
},
});
@@ -149,20 +152,55 @@ export default function Library() {
)}
{/* Info */}
<div className="flex-1">
<h3 className="text-base font-bold text-light-900 mb-1 line-clamp-2">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-sm text-light-500 mb-2">{podcast.author}</p>
)}
<div className="flex gap-3 text-xs text-light-400 mb-4">
{podcast.episodeCount > 0 && (
<span>{podcast.episodeCount} épisodes</span>
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-light-900 mb-1 truncate">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-xs text-light-500 mb-2 truncate">{podcast.author}</p>
)}
{/* Description preview */}
{podcast.description && (
<p className="text-xs text-light-400 line-clamp-2 mb-3 h-8 leading-relaxed">
{podcast.description}
</p>
)}
<div className="flex flex-wrap gap-2 text-[10px] text-light-400 mb-4">
{podcast.episodeCount > 0 && (
<span className="bg-light-100 px-2 py-0.5 rounded-full">
{podcast.episodeCount} épisodes
</span>
)}
{podcast.language && (
<span className="bg-light-100 px-2 py-0.5 rounded-full uppercase">
{podcast.language}
</span>
)}
</div>
{/* Last Episode info */}
{podcast.lastEpisodeTitle && (
<div className="bg-blue-50/50 rounded-lg p-2.5 mb-2 border border-blue-100/50">
<p className="text-[10px] font-semibold text-blue-600 uppercase tracking-wider mb-1">
Dernier épisode
</p>
<p className="text-xs font-medium text-light-800 line-clamp-1 mb-0.5">
{podcast.lastEpisodeTitle}
</p>
{podcast.lastEpisodeDate && (
<p className="text-[10px] text-light-400">
{new Date(podcast.lastEpisodeDate).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric'
})}
</p>
)}
</div>
)}
{podcast.language && <span>{podcast.language}</span>}
</div>
</div>
{/* Unsubscribe button */}
{confirmDeleteId === podcast._id ? (
+2
View File
@@ -10,6 +10,8 @@ export interface Podcast {
category: string[];
language: string;
episodeCount: number;
lastEpisodeTitle?: string;
lastEpisodeDate?: string;
lastFetched: string;
}