mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
feat: delete posts/threads
This commit is contained in:
@@ -270,7 +270,7 @@ class PostController extends OCSController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post
|
||||
* Delete a post (soft delete)
|
||||
*
|
||||
* @param int $id Post ID
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
|
||||
@@ -278,12 +278,41 @@ class PostController extends OCSController {
|
||||
* 200: Post deleted
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canModerate', resourceType: 'category', resourceIdFromPostId: 'id')]
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/posts/{id}')]
|
||||
public function destroy(int $id): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$post = $this->postMapper->find($id);
|
||||
$this->postMapper->delete($post);
|
||||
|
||||
// Check if user is the author OR has moderator permission
|
||||
$isAuthor = $post->getAuthorId() === $user->getUID();
|
||||
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
|
||||
$isModerator = $this->permissionService->hasCategoryPermission($user->getUID(), $categoryId, 'canModerate');
|
||||
|
||||
if (!$isAuthor && !$isModerator) {
|
||||
return new DataResponse(['error' => 'Insufficient permissions to delete this post'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Soft delete the post
|
||||
$post->setDeletedAt(time());
|
||||
$post->setUpdatedAt(time());
|
||||
$this->postMapper->update($post);
|
||||
|
||||
// Update thread post count
|
||||
try {
|
||||
$thread = $this->threadMapper->find($post->getThreadId());
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
$thread->setUpdatedAt(time());
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if thread update fails
|
||||
}
|
||||
|
||||
return new DataResponse(['success' => true]);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
|
||||
|
||||
@@ -275,10 +275,10 @@ class ThreadController extends OCSController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thread
|
||||
* Delete a thread (soft delete)
|
||||
*
|
||||
* @param int $id Thread ID
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool, categorySlug: string}, array{}>
|
||||
*
|
||||
* 200: Thread deleted
|
||||
*/
|
||||
@@ -288,8 +288,30 @@ class ThreadController extends OCSController {
|
||||
public function destroy(int $id): DataResponse {
|
||||
try {
|
||||
$thread = $this->threadMapper->find($id);
|
||||
$this->threadMapper->delete($thread);
|
||||
return new DataResponse(['success' => true]);
|
||||
|
||||
// Get category for slug and count updates
|
||||
$category = $this->categoryMapper->find($thread->getCategoryId());
|
||||
$categorySlug = $category->getSlug();
|
||||
|
||||
// Soft delete the thread
|
||||
$thread->setDeletedAt(time());
|
||||
$thread->setUpdatedAt(time());
|
||||
$this->threadMapper->update($thread);
|
||||
|
||||
// Update category counts (decrement thread count and post count)
|
||||
try {
|
||||
$category->setThreadCount(max(0, $category->getThreadCount() - 1));
|
||||
$category->setPostCount(max(0, $category->getPostCount() - $thread->getPostCount()));
|
||||
$this->categoryMapper->update($category);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category counts after thread deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'categorySlug' => $categorySlug,
|
||||
]);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -30,6 +30,8 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setCreatedAt(int $value)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $value)
|
||||
* @method int|null getDeletedAt()
|
||||
* @method void setDeletedAt(?int $value)
|
||||
*/
|
||||
class Post extends Entity implements JsonSerializable {
|
||||
protected $threadId;
|
||||
@@ -40,6 +42,7 @@ class Post extends Entity implements JsonSerializable {
|
||||
protected $editedAt;
|
||||
protected $createdAt;
|
||||
protected $updatedAt;
|
||||
protected $deletedAt;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'integer');
|
||||
@@ -51,6 +54,7 @@ class Post extends Entity implements JsonSerializable {
|
||||
$this->addType('editedAt', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
$this->addType('deletedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
|
||||
@@ -35,6 +35,9 @@ class PostMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
@@ -51,6 +54,9 @@ class PostMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('slug', $qb->createNamedParameter($slug, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
@@ -66,6 +72,9 @@ class PostMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('created_at', 'ASC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
@@ -83,6 +92,9 @@ class PostMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
@@ -97,6 +109,9 @@ class PostMapper extends QBMapper {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('created_at', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
@@ -107,7 +122,10 @@ class PostMapper extends QBMapper {
|
||||
public function countAll(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName());
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -121,7 +139,10 @@ class PostMapper extends QBMapper {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)));
|
||||
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
@@ -38,6 +38,8 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setCreatedAt(int $value)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $value)
|
||||
* @method int|null getDeletedAt()
|
||||
* @method void setDeletedAt(?int $value)
|
||||
*/
|
||||
class Thread extends Entity implements JsonSerializable {
|
||||
protected $categoryId;
|
||||
@@ -52,6 +54,7 @@ class Thread extends Entity implements JsonSerializable {
|
||||
protected $isHidden;
|
||||
protected $createdAt;
|
||||
protected $updatedAt;
|
||||
protected $deletedAt;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'integer');
|
||||
@@ -67,6 +70,7 @@ class Thread extends Entity implements JsonSerializable {
|
||||
$this->addType('isHidden', 'boolean');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
$this->addType('deletedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
|
||||
@@ -35,6 +35,9 @@ class ThreadMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
@@ -51,6 +54,9 @@ class ThreadMapper extends QBMapper {
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('slug', $qb->createNamedParameter($slug, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
@@ -69,6 +75,9 @@ class ThreadMapper extends QBMapper {
|
||||
->andWhere(
|
||||
$qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('is_pinned', 'DESC')
|
||||
->addOrderBy('updated_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
@@ -84,6 +93,9 @@ class ThreadMapper extends QBMapper {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
)
|
||||
->orderBy('is_pinned', 'DESC')
|
||||
->addOrderBy('updated_at', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
@@ -95,7 +107,10 @@ class ThreadMapper extends QBMapper {
|
||||
public function countAll(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName());
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -109,7 +124,10 @@ class ThreadMapper extends QBMapper {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)));
|
||||
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -148,7 +166,10 @@ class ThreadMapper extends QBMapper {
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
|
||||
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
@@ -374,6 +374,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('deleted_at', 'integer', [
|
||||
'notnull' => false,
|
||||
'unsigned' => true,
|
||||
'default' => null,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['slug'], 'forum_threads_slug_idx');
|
||||
$table->addIndex(['category_id'], 'forum_threads_category_id_idx');
|
||||
@@ -424,6 +429,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('deleted_at', 'integer', [
|
||||
'notnull' => false,
|
||||
'unsigned' => true,
|
||||
'default' => null,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['slug'], 'forum_posts_slug_idx');
|
||||
$table->addIndex(['thread_id'], 'forum_posts_thread_id_idx');
|
||||
|
||||
10
openapi.json
10
openapi.json
@@ -4399,7 +4399,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "post-destroy",
|
||||
"summary": "Delete a post",
|
||||
"summary": "Delete a post (soft delete)",
|
||||
"tags": [
|
||||
"post"
|
||||
],
|
||||
@@ -7224,7 +7224,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "thread-destroy",
|
||||
"summary": "Delete a thread",
|
||||
"summary": "Delete a thread (soft delete)",
|
||||
"tags": [
|
||||
"thread"
|
||||
],
|
||||
@@ -7282,11 +7282,15 @@
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success"
|
||||
"success",
|
||||
"categorySlug"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"categorySlug": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,20 +18,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<NcActions>
|
||||
<NcActionButton @click="$emit('reply', post)">
|
||||
<NcActions ref="actionsMenu">
|
||||
<NcActionButton @click="handleReply">
|
||||
<template #icon>
|
||||
<ReplyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.reply }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="canEdit" @click="startEdit">
|
||||
<NcActionButton v-if="canEdit" @click="handleEditClick">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="canDelete" @click="$emit('delete', post)">
|
||||
<NcActionButton v-if="canDelete" @click="handleDelete">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
@@ -104,6 +104,7 @@ export default defineComponent({
|
||||
reply: t('forum', 'Reply'),
|
||||
edit: t('forum', 'Edit'),
|
||||
delete: t('forum', 'Delete'),
|
||||
confirmDelete: t('forum', 'Are you sure you want to delete this post? This action cannot be undone.'),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -125,6 +126,35 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeActionsMenu() {
|
||||
const menu = this.$refs.actionsMenu as any
|
||||
if (menu && typeof menu.closeMenu === 'function') {
|
||||
menu.closeMenu()
|
||||
}
|
||||
},
|
||||
|
||||
handleReply() {
|
||||
this.closeActionsMenu()
|
||||
this.$emit('reply', this.post)
|
||||
},
|
||||
|
||||
handleEditClick() {
|
||||
this.closeActionsMenu()
|
||||
this.startEdit()
|
||||
},
|
||||
|
||||
handleDelete() {
|
||||
this.closeActionsMenu()
|
||||
|
||||
// Confirm deletion
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!confirm(this.strings.confirmDelete)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('delete', this.post)
|
||||
},
|
||||
|
||||
handleReactionsUpdate(reactions: ReactionGroup[]) {
|
||||
// Update the post's reactions locally
|
||||
if (this.post.reactions !== undefined) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton @click="submitEdit" :disabled="!canSubmit || submitting" type="primary">
|
||||
<template v-if="submitting">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.save }}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton @click="submitReply" :disabled="!canSubmit || submitting">
|
||||
<template v-if="submitting">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.submit }}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton @click="submitThread" :disabled="!canSubmit || submitting" type="primary">
|
||||
<template v-if="submitting">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.submit }}
|
||||
|
||||
@@ -283,12 +283,37 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
async handleDelete(post: Post): Promise<void> {
|
||||
console.log('Delete post:', post.id)
|
||||
// TODO: Implement delete functionality with confirmation
|
||||
// if (confirm(t('forum', 'Are you sure you want to delete this post?'))) {
|
||||
// await ocs.delete(`/posts/${post.id}`)
|
||||
// await this.refresh()
|
||||
// }
|
||||
try {
|
||||
// If this is the first post, we're deleting the entire thread
|
||||
const isFirstPost = this.posts.length > 0 && this.posts[0].id === post.id
|
||||
|
||||
if (isFirstPost) {
|
||||
// Delete thread
|
||||
const response = await ocs.delete<{ success: boolean; categorySlug: string }>(
|
||||
`/threads/${this.thread!.id}`
|
||||
)
|
||||
|
||||
if (response.data?.success && response.data.categorySlug) {
|
||||
showSuccess(t('forum', 'Thread deleted successfully'))
|
||||
// Navigate to the category
|
||||
this.$router.push(`/c/${response.data.categorySlug}`)
|
||||
}
|
||||
} else {
|
||||
// Delete post optimistically
|
||||
await ocs.delete(`/posts/${post.id}`)
|
||||
|
||||
// Remove the post from the local array without refreshing
|
||||
const index = this.posts.findIndex((p) => p.id === post.id)
|
||||
if (index !== -1) {
|
||||
this.posts.splice(index, 1)
|
||||
}
|
||||
|
||||
showSuccess(t('forum', 'Post deleted successfully'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete post', e)
|
||||
showError(t('forum', 'Failed to delete post'))
|
||||
}
|
||||
},
|
||||
|
||||
replyToThread(): void {
|
||||
|
||||
Reference in New Issue
Block a user