feat: delete posts/threads

This commit is contained in:
2025-11-09 23:13:36 +02:00
parent b22284f5ed
commit 38af85bdd2
13 changed files with 198 additions and 28 deletions

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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');

View File

@@ -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"
}
}
}

View File

@@ -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) {

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 {