Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Currently logged actions (defined in `AuditAction` enum):
| `USER_ROLE_CHANGED` | `PATCH /users/:id/role` | `{ before: string, after: string }` |
| `USER_DELETED` | `DELETE /users/:id` | `{ email: string }` |
| `USER_LOGIN_FAILED` | `POST /auth/login` (on invalid credentials) | `{ email: string }` |
| `POST_SOFT_DELETED` | `DELETE /creators/me/posts/:id` | — |
| `POST_RESTORED` | `POST /creators/me/posts/:id/restore` | — |

### Adding a New Action

Expand Down
129 changes: 129 additions & 0 deletions docs/post-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Post Lifecycle: Soft-Delete, Archive, Restore

## Why

Posts used to be **hard-deleted** (`DELETE /creators/me/posts/:id` removed the
row). Users are soft-deleted (`User.is_deleted`); posts now follow the same
pattern so creators can recover accidental deletions and so dependent
engagement (likes/comments, once built) has a stable target to reason about.

There is **no hard-delete path via the creator API**. Deleted posts remain in
the database indefinitely (see [Out of scope](#out-of-scope)).

## Data model

`Post` gains two nullable columns (migration
`src/migrations/1790000000000-AddPostSoftDelete.ts`):

| Field | Type | Notes |
| ------------- | ---------------- | ------------------------------------------------- |
| `deletedAt` | `timestamp \| null` | Set on soft-delete, cleared on restore. `null` = active. |
| `deletedById` | `int \| null` | Who deleted it. FK → `users.id`, `ON DELETE SET NULL`. |

Pre-existing rows are backfilled with `deletedAt = NULL` (i.e. unaffected).

### Indexes

Every active-post read path filters `deletedAt IS NULL`, so the original
`(creatorId, publishedAt)` and `(visibility, publishedAt)` indexes were
replaced with **partial indexes** scoped to `WHERE "deletedAt" IS NULL`
(`IDX_posts_creator_published_active`,
`IDX_posts_visibility_published_active`). A plain
`(creatorId, deletedAt)` index (`IDX_posts_creator_deletedAt`) backs the
archive listing, which filters `deletedAt IS NOT NULL`.

## Endpoints

| Method | Path | Auth | Behavior |
| ------ | ----------------------------------- | ------ | -------- |
| DELETE | `/creators/me/posts/:id` | Bearer | Soft-delete: sets `deletedAt`/`deletedById`, returns `204`. |
| GET | `/creators/me/posts/archived` | Bearer | Paginated list of the caller's soft-deleted posts. |
| POST | `/creators/me/posts/:id/restore` | Bearer | Clears `deletedAt`/`deletedById`, returns the restored post (`200`). |

No purge/hard-delete endpoint exists. If a legal or compliance need for
permanent erasure arises, it should be handled by the GDPR export/wipe flow
(see `src/users/services/gdpr.service.ts`), not this API — media-binary legal
holds are explicitly out of scope here.

## Read-path filtering

All of these exclude soft-deleted posts (`deletedAt IS NULL`):

- `GET /creators/me/posts` — owner's active list (deleted posts move to the
archive endpoint instead).
- `GET /creators/:handle/posts` — public/subscriber-aware creator feed.
- `PostsService.getPostById` — single-post lookup (not yet wired to a route,
but any future route must use it or `assertPostIsEngageable` rather than
querying `Post` directly).

`GET /creators/me/posts/archived` is the only read path that returns
soft-deleted posts, and only to the owner.

## Authorization

- **Delete/restore:** only the post's `creatorId` may act on it.
- Wrong owner → `403 Forbidden`.
- Post doesn't exist at all (any state) → `404 Not Found`.
- No admin override exists yet; add one deliberately (with its own audit
action) if/when moderation needs to force-delete a post.

## Idempotency

- **Delete an already-deleted post:** `204 No-op`. The service checks
`post.deletedAt` first and returns without touching the row or emitting an
audit event, so double-clicking "delete" is safe.
- **Restore a post that isn't deleted:** `409 Conflict`. This was a
deliberate choice over a silent no-op — restoring implies a specific
soft-deleted state existed, and returning `409` gives the client explicit
feedback that nothing needed restoring, rather than masking a
possible client-side bug (e.g. restoring the wrong id).

## Engagement (likes/comments) on deleted posts

Likes and comments don't exist yet in this codebase. The contract any future
implementation **must** follow:

- **New engagement on a deleted (or missing) post → `404 Not Found`.**
`PostsService.assertPostIsEngageable(postId)` is the enforcement point:
it does the same `deletedAt IS NULL` lookup as `getPostById` and throws
`NotFoundException` otherwise. Future `LikesService`/`CommentsService`
implementations should call it before writing a like/comment, instead of
querying `Post` directly.
- **Existing engagement on a post that gets deleted → hidden implicitly.**
Because the post itself disappears from every public/owner-active read
path, any comments/likes attached to it become unreachable through normal
navigation. There is no separate "tombstone" placeholder shown in feeds —
the post is simply absent, exactly like any other soft-deleted post. If a
future UI needs to show "this post was removed" inline (e.g. a reply in a
thread pointing at a deleted parent), that tombstone rendering is a
client/API-shape concern for whichever feature introduces threaded
replies, not something this issue's scope requires stubbing out.

### Known related limitation (documented, not fixed here)

`ModerationService.assertTargetExists` (content reports) still looks up
posts without a `deletedAt` filter, so a post can technically still be
*reported* after it's been soft-deleted. This is unchanged by this work —
moderation's target-existence check is out of scope for this issue — but is
worth revisiting alongside a future moderation change, since reporting a
post a creator already deleted is low-value.

## Audit

Two new `AuditAction` values (`src/audit/audit-action.enum.ts`):

- `POST_SOFT_DELETED` — logged on every non-idempotent delete, `targetType:
'Post'`, `targetId: <postId>`, `actorId: <creatorId>`.
- `POST_RESTORED` — logged on every successful restore, same shape.

No-op deletes and failed restores (403/404/409) do **not** emit an audit
entry — only state transitions are logged, consistent with the rest of the
audit log (see `docs/audit-log.md`).

## Out of scope

- Legal hold / GDPR wipe of media binaries — see the GDPR export flow
instead.
- Admin-only hard purge — no endpoint exists; if retention policy requires
one later, add it as a separate admin-guarded route with its own audit
action, not a variant of the creator-facing delete.
133 changes: 133 additions & 0 deletions src/migrations/1790000000000-AddPostSoftDelete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
MigrationInterface,
QueryRunner,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';

export class AddPostSoftDelete1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'posts',
new TableColumn({
name: 'deletedAt',
type: 'timestamp',
isNullable: true,
default: null,
}),
);

await queryRunner.addColumn(
'posts',
new TableColumn({
name: 'deletedById',
type: 'int',
isNullable: true,
default: null,
}),
);

// Backfill: pre-existing rows are undeleted.
await queryRunner.query(
`UPDATE "posts" SET "deletedAt" = NULL WHERE "deletedAt" IS NULL`,
);

await queryRunner.createForeignKey(
'posts',
new TableForeignKey({
columnNames: ['deletedById'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'SET NULL',
}),
);

// Replace the original active-post indexes with partial indexes that
// exclude soft-deleted rows, since every public/owner read path now
// filters on deletedAt IS NULL.
const table = await queryRunner.getTable('posts');
if (table) {
const creatorPublishedIndex = table.indices.find(
(index) =>
index.columnNames.length === 2 &&
index.columnNames.includes('creatorId') &&
index.columnNames.includes('publishedAt'),
);
if (creatorPublishedIndex) {
await queryRunner.dropIndex('posts', creatorPublishedIndex);
}

const visibilityPublishedIndex = table.indices.find(
(index) =>
index.columnNames.length === 2 &&
index.columnNames.includes('visibility') &&
index.columnNames.includes('publishedAt'),
);
if (visibilityPublishedIndex) {
await queryRunner.dropIndex('posts', visibilityPublishedIndex);
}
}

await queryRunner.createIndex(
'posts',
new TableIndex({
name: 'IDX_posts_creator_published_active',
columnNames: ['creatorId', 'publishedAt'],
where: '"deletedAt" IS NULL',
}),
);

await queryRunner.createIndex(
'posts',
new TableIndex({
name: 'IDX_posts_visibility_published_active',
columnNames: ['visibility', 'publishedAt'],
where: '"deletedAt" IS NULL',
}),
);

await queryRunner.createIndex(
'posts',
new TableIndex({
name: 'IDX_posts_creator_deletedAt',
columnNames: ['creatorId', 'deletedAt'],
}),
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('posts', 'IDX_posts_creator_deletedAt');
await queryRunner.dropIndex(
'posts',
'IDX_posts_visibility_published_active',
);
await queryRunner.dropIndex('posts', 'IDX_posts_creator_published_active');

await queryRunner.createIndex(
'posts',
new TableIndex({
columnNames: ['visibility', 'publishedAt'],
}),
);
await queryRunner.createIndex(
'posts',
new TableIndex({
columnNames: ['creatorId', 'publishedAt'],
}),
);

const table = await queryRunner.getTable('posts');
if (table) {
const foreignKey = table.foreignKeys.find(
(fk) => fk.columnNames.indexOf('deletedById') !== -1,
);
if (foreignKey) {
await queryRunner.dropForeignKey('posts', foreignKey);
}
}

await queryRunner.dropColumn('posts', 'deletedById');
await queryRunner.dropColumn('posts', 'deletedAt');
}
}
7 changes: 7 additions & 0 deletions src/posts/dtos/post-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,11 @@ export class PostResponseDto {
description: 'Last update timestamp',
})
updatedAt: Date;

@ApiPropertyOptional({
example: null,
description: 'When the post was soft-deleted, or null if active',
nullable: true,
})
deletedAt: Date | null;
}
4 changes: 2 additions & 2 deletions src/posts/post.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { User } from '../users/user.entity';
type Visibility = 'public' | 'subscribers';

@Entity('posts')
@Index(['creatorId', 'publishedAt'])
@Index(['visibility', 'publishedAt'])
@Index(['creatorId', 'publishedAt'], { where: '"deletedAt" IS NULL' })
@Index(['visibility', 'publishedAt'], { where: '"deletedAt" IS NULL' })
@Index(['creatorId', 'visibility'])
@Index(['creatorId', 'deletedAt', 'publishedAt'])
export class Post {
Expand Down
54 changes: 52 additions & 2 deletions src/posts/posts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ export class PostsController {
);
}

@Get('me/posts/archived')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get soft-deleted posts by authenticated creator (paginated)',
})
@ApiResponse({
status: 200,
description: 'Archived posts retrieved successfully',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiQuery({ name: 'page', required: false, example: 1 })
@ApiQuery({ name: 'limit', required: false, example: 10 })
async getArchivedPosts(
@Query() query: PaginationQueryDto,
@Req() req: AuthenticatedRequest,
) {
return this.postsService.getArchivedPosts(
req.user.userId,
query.page,
query.limit,
);
}

@Get(':handle/posts')
@UseGuards(OptionalJwtAuthGuard)
@ApiOperation({
Expand Down Expand Up @@ -173,8 +197,13 @@ export class PostsController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a post' })
@ApiResponse({ status: 204, description: 'Post deleted successfully' })
@ApiOperation({
summary: 'Soft-delete a post (moves it to the archive, idempotent)',
})
@ApiResponse({
status: 204,
description: 'Post soft-deleted (or already deleted)',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({
status: 403,
Expand All @@ -188,4 +217,25 @@ export class PostsController {
) {
await this.postsService.deletePost(postId, req.user.userId);
}

@Post('me/posts/:id/restore')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@HttpCode(200)
@ApiOperation({ summary: 'Restore a soft-deleted post' })
@ApiResponse({ status: 200, description: 'Post restored successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({
status: 403,
description: "Cannot restore another creator's post",
})
@ApiResponse({ status: 404, description: 'Post not found' })
@ApiResponse({ status: 409, description: 'Post is not deleted' })
@ApiParam({ name: 'id', description: 'Post ID' })
async restorePost(
@Param('id', ParseIntPipe) postId: number,
@Req() req: AuthenticatedRequest,
) {
return this.postsService.restorePost(postId, req.user.userId);
}
}
1 change: 1 addition & 0 deletions src/posts/posts.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
],
providers: [PostsService, PostVisibilityService],
controllers: [PostsController],
exports: [PostsService],
})
export class PostsModule {}
Loading
Loading