added admin area to delete feeds
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeedInfo {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeedInfoList {
|
||||
pub feeds: Vec<FeedInfo>,
|
||||
}
|
||||
|
||||
impl Responder for FeedInfoList {
|
||||
type Body = String;
|
||||
|
||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||
let body = serde_json::to_string(&self).unwrap();
|
||||
HttpResponse::with_body(StatusCode::OK, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
schema::{feed, feed_item},
|
||||
};
|
||||
|
||||
pub async fn delete_feed(path: web::Path<i32>) -> HttpResponse {
|
||||
let feed_id = path.into_inner();
|
||||
let mut connection = establish_connection();
|
||||
|
||||
let exists = feed::table
|
||||
.find(feed_id)
|
||||
.count()
|
||||
.get_result::<i64>(&mut connection)
|
||||
.unwrap_or(0);
|
||||
|
||||
if exists == 0 {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
|
||||
diesel::delete(feed_item::table.filter(feed_item::feed_id.eq(feed_id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
|
||||
diesel::delete(feed::table.filter(feed::id.eq(feed_id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
|
||||
HttpResponse::NoContent().finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use super::delete_feed;
|
||||
use crate::database::establish_connection;
|
||||
use crate::schema::{feed, feed_item};
|
||||
use crate::test_helpers::{
|
||||
delete_feed as cleanup_feed, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||
};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn delete_feed_removes_feed_and_items() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
let f = insert_feed(&mut connection, user.id);
|
||||
let item = insert_feed_item(&mut connection, f.id, false);
|
||||
|
||||
let app = test::init_service(
|
||||
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::delete()
|
||||
.uri(&format!("/feed/{}", f.id))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||
|
||||
let feed_exists: i64 = feed::table
|
||||
.filter(feed::id.eq(f.id))
|
||||
.count()
|
||||
.get_result(&mut connection)
|
||||
.unwrap();
|
||||
assert_eq!(0, feed_exists);
|
||||
|
||||
let item_exists: i64 = feed_item::table
|
||||
.filter(feed_item::id.eq(item.id))
|
||||
.count()
|
||||
.get_result(&mut connection)
|
||||
.unwrap();
|
||||
assert_eq!(0, item_exists);
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn delete_feed_returns_404_for_nonexistent_feed() {
|
||||
let app = test::init_service(
|
||||
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::delete()
|
||||
.uri("/feed/999999999")
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn delete_feed_does_not_affect_other_feeds() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
let feed_a = insert_feed(&mut connection, user.id);
|
||||
let feed_b = insert_feed(&mut connection, user.id);
|
||||
|
||||
let app = test::init_service(
|
||||
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::delete()
|
||||
.uri(&format!("/feed/{}", feed_a.id))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||
|
||||
let feed_b_exists: i64 = feed::table
|
||||
.filter(feed::id.eq(feed_b.id))
|
||||
.count()
|
||||
.get_result(&mut connection)
|
||||
.unwrap();
|
||||
assert_eq!(1, feed_b_exists);
|
||||
|
||||
cleanup_feed(&mut connection, feed_b.id);
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use actix_web::{web, HttpRequest, Responder};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
json_serialization::feed_info::{FeedInfo, FeedInfoList},
|
||||
json_serialization::user::JsonUser,
|
||||
schema::feed::{self, user_id},
|
||||
};
|
||||
|
||||
pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
|
||||
let request = req.clone();
|
||||
let req_user_id = path.user_id;
|
||||
|
||||
let mut connection = establish_connection();
|
||||
let feeds = feed::table
|
||||
.filter(user_id.eq(req_user_id))
|
||||
.select((feed::id, feed::title, feed::url))
|
||||
.load::<(i32, String, String)>(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
let feed_list: Vec<FeedInfo> = feeds
|
||||
.into_iter()
|
||||
.map(|(id, title, url)| FeedInfo { id, title, url })
|
||||
.collect();
|
||||
|
||||
FeedInfoList { feeds: feed_list }.respond_to(&request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App};
|
||||
|
||||
use super::list_feeds;
|
||||
use crate::database::establish_connection;
|
||||
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn list_feeds_returns_feeds_for_user() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
let feed = insert_feed(&mut connection, user.id);
|
||||
|
||||
let app = test::init_service(
|
||||
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/feeds/{}", user.id))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::OK, resp.status());
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains(&feed.title));
|
||||
assert!(body_str.contains(&feed.url));
|
||||
|
||||
delete_feed(&mut connection, feed.id);
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn list_feeds_returns_empty_list_for_user_with_no_feeds() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
let app = test::init_service(
|
||||
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/feeds/{}", user.id))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::OK, resp.status());
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("\"feeds\":[]"));
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn list_feeds_does_not_return_other_users_feeds() {
|
||||
let mut connection = establish_connection();
|
||||
let user_a = insert_user(&mut connection, "secret");
|
||||
let user_b = insert_user(&mut connection, "secret");
|
||||
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||
|
||||
let app = test::init_service(
|
||||
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/feeds/{}", user_a.id))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::OK, resp.status());
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(!body_str.contains(&feed_b.title));
|
||||
|
||||
delete_feed(&mut connection, feed_b.id);
|
||||
delete_user(&mut connection, user_a.id);
|
||||
delete_user(&mut connection, user_b.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const feeds = ref([])
|
||||
const error = ref('')
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'user-token': localStorage.getItem('user-token'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeeds() {
|
||||
const userId = localStorage.getItem('user-id')
|
||||
try {
|
||||
const response = await axios.get(`/api/v1/article/feeds/${userId}`, authHeaders())
|
||||
feeds.value = response.data.feeds
|
||||
} catch (e) {
|
||||
error.value = 'Failed to load feeds.'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFeed(feedId) {
|
||||
if (!window.confirm('Delete this feed and all its articles?')) return
|
||||
try {
|
||||
await axios.delete(`/api/v1/article/feed/${feedId}`, authHeaders())
|
||||
feeds.value = feeds.value.filter(f => f.id !== feedId)
|
||||
} catch (e) {
|
||||
error.value = 'Failed to delete feed.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFeeds)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h1 class="admin__heading">Admin</h1>
|
||||
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||
<ul v-else class="admin__list">
|
||||
<li v-for="feed in feeds" :key="feed.id" class="admin__item">
|
||||
<div class="admin__item-info">
|
||||
<span class="admin__item-title">{{ feed.title }}</span>
|
||||
<a :href="feed.url" class="admin__item-url" target="_blank" rel="noopener">{{ feed.url }}</a>
|
||||
</div>
|
||||
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
padding: 1.5rem 1rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin__heading {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.admin__error,
|
||||
.admin__empty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.admin__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.admin__item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin__item-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin__item-url {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.admin__delete {
|
||||
flex-shrink: 0;
|
||||
min-height: 36px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin__delete:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<AdminFeeds />
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user