feat: account deletion with 6-month retention purge - #37
Conversation
🤖 CodeAnt AI — Review Status
|
✅ Deploy Preview for moderaty ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
User descriptionWhatSelf-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:
Migration0008 adds nullable Notes
Verification
CodeAnt-AI DescriptionAdd self-service account deletion with a six-month recovery window and permanent cleanup What Changed
Impact
💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR adds soft account deletion with session invalidation, channel deactivation, six-month retention, sign-in restoration, consent preservation, anonymized purging, and cron integration. It also adds schema migrations, pipeline safeguards, tests, and operating documentation. ChangesAccount deletion lifecycle
Legal consistency test
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
actor User
participant Dashboard
participant Database
participant GoogleOAuth
participant Consent
participant Cron
User->>Dashboard: Submit confirmed account deletion
Dashboard->>Database: Mark user deleted, destroy sessions, deactivate channels
User->>GoogleOAuth: Start sign-in
GoogleOAuth->>Database: Check deletion timestamp and retention window
Database-->>GoogleOAuth: Restore retained account or require fresh signup
GoogleOAuth->>Consent: Redirect for consent when required
Consent->>Database: Clear deletion timestamp and create session after consent
Cron->>Database: Select and purge one expired deleted user
Database-->>Cron: Return purged user ID or purge error
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
PR Summary by QodoAccount deletion with 6-month retention and bounded cron purge
AI Description
Diagram
High-Level Assessment
Files changed (14)
|
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | ✅ 68 (≤ 100 complexity) |
| Duplication | ✅ -10 (≤ 1 duplication) |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Summary
This PR implements account deletion with soft-delete and 6-month retention. The implementation is generally sound with comprehensive test coverage (238/238 tests passing). However, I've identified 4 critical defects that must be addressed before merge:
Critical Issues Found
- Race condition in account restoration - Users can restore accounts after the 6-month retention period has expired if they sign in after the cutoff but before the cron purge runs
- Transaction rollback handling - The deleteAccount transaction could leave inconsistent state if operations fail partway through
- Error handling in purge - The cron endpoint may return success even if purge fails, masking data integrity issues
- Empty array handling - The purge logic may crash when processing users with no channels, depending on ORM behavior with empty arrays
Security & Compliance
The implementation correctly preserves consent records per LGPD requirements and uses proper soft-delete with tombstone anonymization. The 6-month retention window is appropriate for data protection compliance.
Next Steps
Address the 4 critical findings above, particularly the race condition in restoration logic and empty array handling, before merging. Consider adding test coverage for edge cases like restoring after retention expiry and purging users with no channels.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
| await db.transaction(async (tx) => { | ||
| await tx.update(users).set({ deletedAt: new Date().toISOString() }).where(eq(users.id, user.id)); | ||
| await tx.delete(sessions).where(eq(sessions.userId, user.id)); | ||
| await tx.update(channels).set({ active: 0 }).where(eq(channels.userId, user.id)); | ||
| }); |
There was a problem hiding this comment.
🛑 Crash Risk: The transaction marks deletedAt and deactivates channels, but if session deletion fails mid-transaction, the user might retain an active session with a "deleted" account status. This could allow continued API access until session expiry despite account deletion. The transaction will rollback on failure, but error handling should explicitly verify all operations succeeded.
| purged = await purgeExpiredUser(); | ||
| } |
There was a problem hiding this comment.
🛑 Data Integrity Risk: The purge function performs cascading deletes across multiple tables outside a transaction scope. If the database operation fails partway through (e.g., between deleting comments and rules), the user's data will be partially deleted, leaving orphaned records. The inner transaction at line 58 protects the purge logic, but wrapping the purgeExpiredUser() call itself in error handling would prevent returning a success response if purge fails.
Code Review by Qodo
Context used✅ Compliance rules (platform):
62 rules 1.
|
Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com>
Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com>
🏁 CodeAnt Quality Gate ResultsCommit: ✅ Overall Status: PASSEDQuality Gate Details
|
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/routes/api/auth/google/login/callback/`+server.ts:
- Around line 91-94: Update src/routes/api/auth/google/login/callback/+server.ts
lines 91-94 around the deletedAt restoration logic to restore accounts only when
the deletion timestamp is within the purge’s six-month retention cutoff; reject
expired deletion markers without creating a session. Update
src/routes/api/auth/google/login/login.test.ts lines 248-263 to use a controlled
clock, cover rejection of expired markers, and verify an inactive channel
remains inactive after valid restoration.
In `@src/routes/api/auth/google/login/login.test.ts`:
- Around line 248-263: Make the signing-back-in restoration test deterministic
by freezing the test clock and deriving the soft-deletion timestamp from it,
rather than using a fixed date. Extend coverage around loginCallback with an
expired-marker case that rejects, preserves users.deletedAt, and creates no
sessions. Seed an inactive channel for the in-window case and assert restoration
preserves its inactive state, while keeping the existing account restoration
assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bd472de0-1585-4003-aab9-686eb29667f5
📒 Files selected for processing (14)
AGENTS.mdEXECUTION_PLAN_YouTube_Comment_Moderator.mddrizzle/0008_aromatic_red_wolf.sqldrizzle/meta/0008_snapshot.jsondrizzle/meta/_journal.jsonsrc/lib/server/db/schema.tssrc/lib/server/testdb.tssrc/routes/(app)/dashboard/+page.server.tssrc/routes/(app)/dashboard/+page.sveltesrc/routes/(app)/dashboard/dashboard.test.tssrc/routes/api/auth/google/login/callback/+server.tssrc/routes/api/auth/google/login/login.test.tssrc/routes/api/cron/+server.tssrc/routes/api/cron/cron.test.ts
…gn-ins purge inline instead of restoring
…-account-deletion # Conflicts: # src/routes/api/cron/+server.ts
… inactive on restore
|
Note Docstrings generation - SUCCESS |
Docstrings generation was requested by @Bonobo791. The following files were modified: * `src/lib/server/retention.ts` * `src/lib/server/testdb.ts` * `src/routes/(app)/dashboard/+page.server.ts` * `src/routes/api/auth/google/login/callback/+server.ts` These files were ignored: * `src/routes/(app)/dashboard/dashboard.test.ts` * `src/routes/api/auth/google/login/login.test.ts` * `src/routes/api/cron/cron.test.ts` These file types are not supported: * `AGENTS.md` * `EXECUTION_PLAN_YouTube_Comment_Moderator.md` * `drizzle/0008_aromatic_red_wolf.sql` * `drizzle/meta/0008_snapshot.json` * `drizzle/meta/_journal.json` * `src/routes/(app)/dashboard/+page.svelte`
User descriptionWhatSelf-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:
Migration0008 adds nullable Notes
Verification
CodeAnt-AI DescriptionAdd self-service account deletion with a six-month recovery window and permanent cleanup What Changed
Impact
💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
PR Code Suggestions ✨Previous suggestions up to commit
|
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
A stale purge selection can permanently remove an account restored during the race windowThe expired user is selected outside the transaction, but src/lib/server/retention.ts [80-89] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/retention.ts
**Line:** 80:89
**Comment:**
*Race Condition: The expired user is selected outside the transaction, but `purgeUserById` does not verify that the user is still soft-deleted or still has the selected expired timestamp. If the login callback restores the account after this selection and before the purge transaction starts, the purge still deletes the restored account's channels and sessions and anonymizes the user. Include the deletion marker and expected timestamp in an atomic conditional purge.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-01 23:42
|
A concurrent login can recreate a valid session after account deletion signs the user outDeleting existing sessions is not atomic with preventing new session creation. A src/routes/(app)/dashboard/+page.server.ts [96] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 96:96
**Comment:**
*Race Condition: Deleting existing sessions is not atomic with preventing new session creation. A Google login callback can read the user before this transaction, then create a session after this transaction deletes all sessions; session resolution does not check `users.deletedAt`, so that newly created session gives access to an account that was already deleted. Serialize deletion with login/session creation or make session creation conditional on the user still being active.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-01 23:42
| |
Deactivation does not cancel an already-claimed moderation runThe src/routes/(app)/dashboard/+page.server.ts [97] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 97:97
**Comment:**
*Race Condition: The `active = 0` update does not stop a cron run that already passed its active check and claimed this channel. That run can continue calling YouTube and insert comments, moderation actions, and audit rows after deletion commits, so account deletion does not guarantee immediate moderation shutdown. Coordinate the claim/run lifecycle with deletion or re-check ownership and `active` state before every external action and write.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-01 23:42
| |
| Logic error |
Account deletion is cancelled before the user completes the required consent flowClearing src/routes/api/auth/google/login/callback/+server.ts [107-110] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/auth/google/login/callback/+server.ts
**Line:** 107:110
**Comment:**
*Logic Error: Clearing `deletedAt` before checking current consent permanently cancels the deletion as soon as Google authentication succeeds. If the account has stale or missing consent, the callback redirects to `/consent`; abandoning that flow leaves the account restored and prevents the retention purge. Restore the account only after the consent flow completes successfully, or otherwise preserve the pending-deletion state.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-01 23:42
|
A retention purge failure aborts the entire cron invocation before moderation runsThe retention purge runs outside the channel-processing error boundary, so any src/routes/api/cron/+server.ts [62-67] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/cron/+server.ts
**Line:** 62:67
**Comment:**
*Logic Error: The retention purge runs outside the channel-processing error boundary, so any database or transaction failure from `purgeExpiredUser()` rejects the cron request before a channel is selected or claimed. This makes a purge outage stop scheduled moderation for that invocation and returns no structured purge failure. Isolate purge failure from channel execution, or catch and report it while continuing with the channel work.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-01 23:42
|
Previous suggestions up to commit 99e29a5
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
Separate activity validation and persistence permits durable moderation data to be written after deletionThis check and the following src/lib/server/pipeline.ts [538-544] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/pipeline.ts
**Line:** 538:544
**Comment:**
*Race Condition: This check and the following `stageDecisions` transaction are separate operations. Account deletion can commit after the check but before the insert transaction, allowing comments, moderation actions, or audit rows to be written for a channel that is already deactivated. The active-state check must be coupled with the durable write or the write must condition on the channel still being active.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
|
Deactivation during enforcement still allows later moderation actions and state writes to completeThe second check only validates the channel before entering src/lib/server/pipeline.ts [556-558] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/pipeline.ts
**Line:** 556:558
**Comment:**
*Race Condition: The second check only validates the channel before entering `processOutstandingActions`; that function then claims and processes multiple batches with YouTube calls and database updates. Deletion can commit immediately after this check, so later batches can still moderate comments and `persistResults` can update channel state after the account has been deleted. Re-check or enforce the active condition at each batch/write boundary.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
| |
Concurrent account lifecycle changes can cause sign-in to purge a newer deletion unexpectedlyPass the originally observed src/routes/api/auth/google/login/callback/+server.ts [93-97] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/auth/google/login/callback/+server.ts
**Line:** 93:97
**Comment:**
*Race Condition: Pass the originally observed `deletedAt` value to `purgeUserById` and handle a `false` result. The account can be restored and deleted again after the initial lookup; without an expected-marker check, this call can purge the newer deletion rather than the expired deletion that was evaluated.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
| |
An unconditional restore can overwrite a concurrent account deletion and recreate access after sign-outThe restore update is not conditional on the src/routes/api/auth/google/login/callback/+server.ts [124-126] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/auth/google/login/callback/+server.ts
**Line:** 124:126
**Comment:**
*Race Condition: The restore update is not conditional on the `deletedAt` value that was read earlier. If account deletion commits after the callback reads the user but before this update, this statement clears the newly-created deletion marker and the callback then creates a valid session, defeating deletion's immediate global sign-out and allowing the deleted account back in.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
| |
Non-atomic session validation can authenticate a request after account deletion commitsThe deleted-account check is based on a separate, non-transactional read. If account src/lib/server/session.ts [75-85] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/session.ts
**Line:** 75:85
**Comment:**
*Race Condition: The deleted-account check is based on a separate, non-transactional read. If account deletion commits after this select but before the function returns, the stale row is still returned as an authenticated user, allowing a request to proceed after deletion and immediate sign-out should have taken effect. Make the session validation and deletion-state check atomic, or revalidate the session/user state before returning.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
| |
| Performance |
An unbounded purge can consume the entire cron budget and prevent the scheduled channel runThe purge runs synchronously before channel selection but is not given the handler src/routes/api/cron/+server.ts [70-75] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/cron/+server.ts
**Line:** 70:75
**Comment:**
*Performance: The purge runs synchronously before channel selection but is not given the handler deadline. Although only one user is selected, deleting all of that user's channels and every channel-scoped child row can take longer than the 20-second budget; the subsequent `runChannel` then starts with an expired deadline and scheduled moderation is skipped or fails. Bound the purge work or reserve/enforce time for channel processing.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 00:52
|
Latest suggestions up to commit 99e29a5
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
A stale pre-transaction user read can create a session for an anonymized tombstoneThe user is read before the transaction, so a concurrent purge can anonymize this src/routes/consent/+page.server.ts [115-130] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/consent/+page.server.ts
**Line:** 115:130
**Comment:**
*Race Condition: The user is read before the transaction, so a concurrent purge can anonymize this row after the read but before the transaction starts. The transaction then inserts a consent and creates a session for the tombstone; because the stale `deletedAt` value is used, the conditional update is skipped and the anonymized account becomes authenticated. Re-read and validate the account inside the transaction, or use a conditional update that verifies the expected non-tombstone state before creating the session.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Critical | 2026-08-02 00:55
|
… purge, and delete rollback
# Conflicts: # EXECUTION_PLAN_YouTube_Comment_Moderator.md # drizzle/meta/0008_snapshot.json # drizzle/meta/_journal.json
…t-gated restore, cron purge isolation
…plate flagged as clones
User descriptionWhatSelf-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:
Migration0008 adds nullable Notes
Verification
CodeAnt-AI DescriptionAdd self-service account deletion with safe six-month retention and purge handling What Changed
Impact
💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
|
|
@CodeAnt-AI review |
|
CodeAnt AI is running the review. |
User descriptionWhatSelf-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:
Migration0008 adds nullable Notes
Verification
CodeAnt-AI DescriptionAdd self-service account deletion with safe six-month retention handling What Changed
Impact
💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
Sequence DiagramThis diagram shows the self-service deletion transaction and the bounded cron purge added by the PR. Deletion immediately signs the user out and stops moderation, while the later purge removes owned data and preserves an anonymized consent tombstone. sequenceDiagram
participant User
participant Dashboard
participant Backend
participant Database
participant Cron
User->>Dashboard: Confirm account deletion
Dashboard->>Backend: Submit deletion request
Backend->>Database: Soft delete user and stop sessions and channels
Database-->>Backend: Transaction committed
Backend-->>User: Sign out and redirect
Cron->>Backend: Run retention purge
Backend->>Database: Select oldest expired user
Database-->>Backend: Expired user
Backend->>Database: Delete owned data and keep consent tombstone
Backend-->>Cron: Purge result
Generated by CodeAnt AI |
|
CodeAnt AI finished running the review. |
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/routes/consent/consent.test.ts (1)
232-271: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winAdd a test for restoring past the retention cutoff.
Only the in-window case (
deletedAt30 days old) is tested. Add a case wheredeletedAtis already past the retention cutoff when consent completes, asserting the account is not restored. This pairs with the missingisRetentionExpiredcheck insrc/routes/consent/+page.server.ts.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/consent/consent.test.ts` around lines 232 - 271, Add a consent-flow test alongside “a soft-deleted existing user is restored only when the consent flow completes” using a deletedAt timestamp beyond the retention cutoff, then assert consent completion does not clear deletedAt while preserving the expected account/consent/session outcomes. Reuse seedExistingAndConsent and the existing database assertions, and target the isRetentionExpired behavior in the consent action.Source: Coding guidelines
drizzle/meta/_journal.json (1)
61-81: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy liftMigration reachability is broken and unguarded. Journal entries 9 and 10 carry
whenvalues below entry 8, so the Drizzle migrator skips both migrations after 0008 is applied.users.deleted_atandusers_deleted_at_idxthen never exist, whiledb:migratestill exits successfully. The new index guard cannot catch this because it inspects.sqlfile text only.
drizzle/meta/_journal.json#L61-L81: raise thewhenvalues of entries 9 and 10 above entry 8's1785627758830, keeping them strictly increasing withidx.src/lib/server/db/schema-indexes.test.ts#L26-L35: assert that the migration file containingCREATE INDEX users_deleted_at_idxis listed indrizzle/meta/_journal.json, and assert that the journalwhenvalues increase strictly withidx.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@drizzle/meta/_journal.json` around lines 61 - 81, Fix migration reachability in drizzle/meta/_journal.json by raising entries 9 and 10 `when` values above entry 8’s value while keeping them strictly increasing by `idx`; update src/lib/server/db/schema-indexes.test.ts around the migration checks to assert the `CREATE INDEX users_deleted_at_idx` migration is journaled and that all journal `when` values strictly increase with `idx`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/landing/legal.test.ts`:
- Around line 240-241: Update the legal-text validation around RETIRED_PROMISES
to be policy-aware rather than relying only on literal substring checks. For
every surface, assert both the seven-day refund scope and denial of refunds for
unused credits after the window, while recognizing negated compliant wording.
Preserve the existing full-refund coverage and make the assertions fail for
unconditional refund promises.
In `@src/lib/server/db/schema-indexes.test.ts`:
- Around line 26-35: The schema index test should validate Drizzle metadata and
only journaled migrations instead of scanning raw schema text and every SQL
file. Update the test to use getTableConfig(users) when asserting
users_deleted_at_idx targets deleted_at, and load migration files according to
entries in drizzle/meta/_journal.json before checking the CREATE INDEX
statement.
In `@src/lib/server/retention.test.ts`:
- Line 114: Update the retentionCutoffIso assertion to capture Date.now() once
in a local reference-time variable, then use that same value for both the
function input and expected Date calculation. Keep the existing 180-day cutoff
expectation unchanged.
- Around line 30-102: Add test coverage in retention.test.ts by extending
seedUser or the relevant test setup to create rows in comments, rules,
audit_log, moderation_actions, and consents, and add assertions that
purgeUserById removes only the purged user’s child rows while preserving other
users’ channels and sessions. Assert that the purged user’s consent row remains
intact, and add consents to the setupTestDb truncation list so tests remain
isolated.
In `@src/lib/server/retention.ts`:
- Around line 62-83: Guard purgeUserById with DRY_RUN from $env/dynamic/private
so it performs no deletes or anonymizing updates during dry runs. Update the
Google login callback’s purge path to recognize a skipped purge and stop instead
of treating the tombstoned account as a fresh signup while its Google identity
remains claimed.
- Line 27: Replace the fixed-day RETENTION_MS cutoff with a true
six-calendar-month calculation for account retention, and update the associated
account-deletion behavior or copy to use that cutoff. Keep statutory-log
retention references unchanged, since they describe separate data.
In `@src/routes/api/auth/google/login/callback/`+server.ts:
- Around line 28-30: Wrap the sign-in-time purge call in the login callback,
specifically the flow around purgeUserById(user.id), in try/catch handling. On
failure, return the same explicit error(502, '...') pattern and wording style
used by the other failure branches in this function, rather than allowing the
exception to escape; preserve the existing successful login flow.
In `@src/routes/consent/`+page.server.ts:
- Around line 115-129: Move the users lookup into the db.transaction callback,
re-read the current row there, and use that row for both consent insertion and
restoration. Before restoring a soft-deleted account, call isRetentionExpired
with the transaction-fresh deletion timestamp and reject expired accounts using
the callback’s existing retention handling. Ensure concurrent purges cannot lead
to consent insertion or session creation for a tombstoned user.
---
Outside diff comments:
In `@drizzle/meta/_journal.json`:
- Around line 61-81: Fix migration reachability in drizzle/meta/_journal.json by
raising entries 9 and 10 `when` values above entry 8’s value while keeping them
strictly increasing by `idx`; update src/lib/server/db/schema-indexes.test.ts
around the migration checks to assert the `CREATE INDEX users_deleted_at_idx`
migration is journaled and that all journal `when` values strictly increase with
`idx`.
In `@src/routes/consent/consent.test.ts`:
- Around line 232-271: Add a consent-flow test alongside “a soft-deleted
existing user is restored only when the consent flow completes” using a
deletedAt timestamp beyond the retention cutoff, then assert consent completion
does not clear deletedAt while preserving the expected account/consent/session
outcomes. Reuse seedExistingAndConsent and the existing database assertions, and
target the isRetentionExpired behavior in the consent action.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b14d62b7-6258-4834-89f2-390225dc5aa4
📒 Files selected for processing (25)
AGENTS.mdEXECUTION_PLAN_YouTube_Comment_Moderator.mddrizzle/0009_aromatic_red_wolf.sqldrizzle/0010_users_deleted_at_idx.sqldrizzle/meta/0009_snapshot.jsondrizzle/meta/0010_snapshot.jsondrizzle/meta/_journal.jsonsrc/lib/landing/legal.test.tssrc/lib/server/db/schema-indexes.test.tssrc/lib/server/db/schema.tssrc/lib/server/pipeline.test.tssrc/lib/server/pipeline.tssrc/lib/server/retention.test.tssrc/lib/server/retention.tssrc/lib/server/session.test.tssrc/lib/server/session.tssrc/lib/server/testdb.tssrc/routes/(app)/dashboard/+page.server.tssrc/routes/(app)/dashboard/dashboard.test.tssrc/routes/api/auth/google/login/callback/+server.tssrc/routes/api/auth/google/login/login.test.tssrc/routes/api/cron/+server.tssrc/routes/api/cron/cron.test.tssrc/routes/consent/+page.server.tssrc/routes/consent/consent.test.ts
| for (const pattern of RETIRED_PROMISES) { | ||
| expect(text, `${name} still carries a retired promise: ${pattern}`).not.toMatch(pattern); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the refund guard policy-aware.
The loop checks only literal substrings. It can allow an unconditional promise such as Full refunds are available for unused credits. because none of RETIRED_PROMISES matches, while the separate /full refund/i assertion still passes. It can also reject compliant text such as No refund upon cancellation of your account. because the matcher ignores negation. Assert the seven-day refund scope and the post-window unused-credit denial for each surface, or use polarity-aware matching. The canonical policy is defined in src/lib/server/legal.ts:63-64.
As per coding guidelines, tests must fail when the real logic is wrong.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/landing/legal.test.ts` around lines 240 - 241, Update the legal-text
validation around RETIRED_PROMISES to be policy-aware rather than relying only
on literal substring checks. For every surface, assert both the seven-day refund
scope and denial of refunds for unused credits after the window, while
recognizing negated compliant wording. Preserve the existing full-refund
coverage and make the assertions fail for unconditional refund promises.
Source: Coding guidelines
| const schemaSource = readFileSync(new URL('./schema.ts', import.meta.url), 'utf8'); | ||
| const migrations = readdirSync(new URL('../../../../drizzle', import.meta.url)) | ||
| .filter((file) => file.endsWith('.sql')) | ||
| .map((file) => readFileSync(new URL(`../../../../drizzle/${file}`, import.meta.url), 'utf8')) | ||
| .join('\n'); | ||
|
|
||
| test('users.deleted_at is indexed in the schema and in a migration', () => { | ||
| expect(schemaSource).toContain("index('users_deleted_at_idx').on(table.deletedAt)"); | ||
| expect(migrations).toMatch(/CREATE INDEX `?users_deleted_at_idx`? ON `?users`? \(`?deleted_at`?\)/); | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Inspect getTableConfig index typings in the installed drizzle-orm.
set -euo pipefail
jq -r '.dependencies["drizzle-orm"] // .devDependencies["drizzle-orm"]' package.json
fd -t f 'utils.d.ts' node_modules/drizzle-orm/sqlite-core 2>/dev/null | while IFS= read -r f; do
echo "== $f"; cat -n "$f"
done
fd -t f 'indexes.d.ts' node_modules/drizzle-orm/sqlite-core 2>/dev/null | while IFS= read -r f; do
echo "== $f"; cat -n "$f"
doneRepository: Bonobo791/Moderaty
Length of output: 3402
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '== target test =='
cat -n src/lib/server/db/schema-indexes.test.ts
echo '== schema index declarations =='
rg -n -C 3 "users_deleted_at_idx|deletedAt|deleted_at" src/lib/server/db/schema.ts
echo '== migration journal =='
cat -n drizzle/meta/_journal.json
echo '== migration files containing the index =='
rg -l -n "users_deleted_at_idx|CREATE INDEX" drizzle --glob '*.sql' | while IFS= read -r file; do
echo "== $file"
rg -n -C 2 "users_deleted_at_idx|CREATE INDEX" "$file"
done
echo '== migration naming and journal references =='
find drizzle -maxdepth 2 -type f -print | sortRepository: Bonobo791/Moderaty
Length of output: 6859
🏁 Script executed:
#!/bin/bash
set -euo pipefail
node --input-type=module <<'JS'
import { getTableConfig, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
const users = sqliteTable('users', {
deletedAt: text('deleted_at')
}, (table) => [
index('users_deleted_at_idx').on(table.deletedAt)
]);
const { indexes } = getTableConfig(users);
const match = indexes.find((entry) => entry.config.name === 'users_deleted_at_idx');
console.log(JSON.stringify({
indexCount: indexes.length,
configName: match?.config.name,
columns: match?.config.columns.map((column) => ({
constructor: column.constructor.name,
name: column.name,
columnName: column.columnName
}))
}, null, 2));
JS
python3 - <<'PY'
import json
from pathlib import Path
journal = json.loads(Path("drizzle/meta/_journal.json").read_text())
entries = journal["entries"]
tags = [entry["tag"] for entry in entries]
migration = Path("drizzle/0010_users_deleted_at_idx.sql")
print(json.dumps({
"journal_has_index_migration": "0010_users_deleted_at_idx" in tags,
"journal_last_tag": tags[-1],
"migration_exists": migration.exists(),
"migration_sql": migration.read_text(),
}, indent=2))
PYRepository: Bonobo791/Moderaty
Length of output: 1405
Use Drizzle metadata and the migration journal in src/lib/server/db/schema-indexes.test.ts.
- Use
getTableConfig(users)to assertusers_deleted_at_idxtargetsdeleted_at. - Read only migrations listed in
drizzle/meta/_journal.json; concatenating every.sqlfile allows an unjournaled migration to pass.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/db/schema-indexes.test.ts` around lines 26 - 35, The schema
index test should validate Drizzle metadata and only journaled migrations
instead of scanning raw schema text and every SQL file. Update the test to use
getTableConfig(users) when asserting users_deleted_at_idx targets deleted_at,
and load migration files according to entries in drizzle/meta/_journal.json
before checking the CREATE INDEX statement.
Source: Coding guidelines
| async function seedUser(id: string, deletedAt: string | null) { | ||
| await testDb().db.insert(users).values({ id, googleSub: `sub-${id}`, email: `${id}@example.com`, displayName: id, deletedAt }); | ||
| await testDb().db.insert(channels).values({ id: `UC-${id}`, userId: id, title: `channel ${id}`, refreshTokenEnc: 'enc', active: 0 }); | ||
| await testDb().db.insert(sessions).values({ id: `token-${id}`, userId: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() }); | ||
| return id; | ||
| } | ||
|
|
||
| async function userRow(id: string) { | ||
| return await testDb().db.select().from(users).where(eq(users.id, id)).get(); | ||
| } | ||
|
|
||
| test('purges an expired soft-deleted user: owned rows removed, tombstone anonymized', async () => { | ||
| const expiredAt = new Date(Date.now() - 181 * DAY_MS).toISOString(); | ||
| const userId = await seedUser('purge-me', expiredAt); | ||
|
|
||
| const purged = await purgeUserById(userId, expiredAt); | ||
|
|
||
| expect(purged).toBe(true); | ||
| expect(await testDb().db.select().from(channels).all()).toEqual([]); | ||
| expect(await testDb().db.select().from(sessions).all()).toEqual([]); | ||
| expect(await userRow(userId)).toMatchObject({ | ||
| googleSub: `deleted:${userId}`, | ||
| email: '[deleted]', | ||
| displayName: '[deleted]', | ||
| deletedAt: null | ||
| }); | ||
| }); | ||
|
|
||
| test('race: skips the purge when the account was restored after selection', async () => { | ||
| // purgeExpiredUser selected this user at `selectedAt`; before the purge | ||
| // transaction starts, the login callback restores the account. | ||
| const selectedAt = new Date(Date.now() - 181 * DAY_MS).toISOString(); | ||
| const userId = await seedUser('restored', selectedAt); | ||
| await testDb().db.update(users).set({ deletedAt: null }).where(eq(users.id, userId)); | ||
|
|
||
| const purged = await purgeUserById(userId, selectedAt); | ||
|
|
||
| expect(purged).toBe(false); | ||
| expect(await userRow(userId)).toMatchObject({ | ||
| googleSub: 'sub-restored', | ||
| email: 'restored@example.com', | ||
| deletedAt: null | ||
| }); | ||
| expect(await testDb().db.select().from(channels).all()).toHaveLength(1); | ||
| expect(await testDb().db.select().from(sessions).all()).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('race: skips the purge when the deletion marker changed after selection', async () => { | ||
| // The account was restored and deleted again — the new deleted_at is NOT | ||
| // the expired timestamp the selection saw, so purging on it is wrong. | ||
| const selectedAt = new Date(Date.now() - 181 * DAY_MS).toISOString(); | ||
| const userId = await seedUser('re-deleted', new Date().toISOString()); | ||
|
|
||
| const purged = await purgeUserById(userId, selectedAt); | ||
|
|
||
| expect(purged).toBe(false); | ||
| expect(await userRow(userId)).toMatchObject({ googleSub: 'sub-re-deleted', email: 're-deleted@example.com' }); | ||
| expect(await testDb().db.select().from(channels).all()).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('purgeExpiredUser purges only the oldest expired user and leaves the rest alone', async () => { | ||
| const older = await seedUser('older', new Date(Date.now() - 200 * DAY_MS).toISOString()); | ||
| const newer = await seedUser('newer', new Date(Date.now() - 190 * DAY_MS).toISOString()); | ||
| const withinWindow = await seedUser('within', new Date(Date.now() - 10 * DAY_MS).toISOString()); | ||
| await seedUser('active', null); | ||
|
|
||
| expect(await purgeExpiredUser()).toBe(older); | ||
| expect(await userRow(older)).toMatchObject({ googleSub: `deleted:${older}` }); | ||
| // The next-oldest expired user drains on a later run (bounded runs, I10). | ||
| expect(await userRow(newer)).toMatchObject({ googleSub: 'sub-newer' }); | ||
| expect(await userRow(withinWindow)).toMatchObject({ googleSub: 'sub-within' }); | ||
| expect(await userRow('active')).toMatchObject({ googleSub: 'sub-active' }); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Add coverage for the child-table deletes, cross-user isolation, and consent preservation.
seedUser creates only a user, a channel, and a session. No test creates rows in comments, rules, audit_log, moderation_actions, or consents.
Three defects in purgeUserById pass this suite today:
- Lines 70-73 of
src/lib/server/retention.tsdeletemoderationActions,comments,auditLog, andrules. Remove any of those four statements and every test still passes. - Change
eq(channels.userId, userId)on line 75 to an unscoped delete and the suite still passes. Test 4 seeds four users but asserts onlyusersrows, never the other users' channels and sessions. - Add a
tx.delete(consents)to the purge and the suite still passes. Consent preservation is the stated legal reason for the tombstone design, and it has no test.
Add consents to the setupTestDb truncation list on line 26 before adding the consent assertion.
💚 Proposed coverage additions
-setupTestDb(['moderation_actions', 'comments', 'audit_log', 'rules', 'channels', 'sessions', 'users']);
+setupTestDb([
+ 'moderation_actions',
+ 'comments',
+ 'audit_log',
+ 'rules',
+ 'consents',
+ 'channels',
+ 'sessions',
+ 'users'
+]); async function seedUser(id: string, deletedAt: string | null) {
await testDb().db.insert(users).values({ id, googleSub: `sub-${id}`, email: `${id}`@example.com``, displayName: id, deletedAt });
await testDb().db.insert(channels).values({ id: `UC-${id}`, userId: id, title: `channel ${id}`, refreshTokenEnc: 'enc', active: 0 });
await testDb().db.insert(sessions).values({ id: `token-${id}`, userId: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() });
+ await testDb().db.insert(rules).values({ channelId: `UC-${id}`, type: 'keyword', pattern: id, action: 'reject' });
+ await testDb().db.insert(comments).values({
+ id: `c-${id}`, channelId: `UC-${id}`, text: 'hi', publishedAt: new Date().toISOString(),
+ status: 'approved', decidedBy: 'rule'
+ });
+ await testDb().db.insert(auditLog).values({
+ channelId: `UC-${id}`, commentId: `c-${id}`, action: 'approve', reason: 'r', actor: 'cron'
+ });
+ await testDb().db.insert(moderationActions).values({
+ commentId: `c-${id}`, channelId: `UC-${id}`, action: 'reject', reason: 'r', state: 'done'
+ });
+ await testDb().db.insert(consents).values({
+ userId: id, docVersion: 'v1', checkboxText: 'text', ip: '127.0.0.1', userAgent: 'test'
+ });
return id;
}+test('purge removes only the target user rows and keeps the consent log', async () => {
+ const expiredAt = new Date(Date.now() - 181 * DAY_MS).toISOString();
+ const target = await seedUser('purge-me', expiredAt);
+ const other = await seedUser('keep-me', null);
+
+ expect(await purgeUserById(target, expiredAt)).toBe(true);
+
+ for (const table of [comments, rules, auditLog, moderationActions]) {
+ const rows = await testDb().db.select().from(table).all();
+ expect(rows.map((row) => row.channelId)).toEqual([`UC-${other}`]);
+ }
+ expect((await testDb().db.select().from(channels).all()).map((ch) => ch.id)).toEqual([`UC-${other}`]);
+ expect((await testDb().db.select().from(sessions).all()).map((s) => s.userId)).toEqual([other]);
+ // The evidentiary consent log survives the purge for BOTH users.
+ expect((await testDb().db.select().from(consents).all()).map((c) => c.userId).sort()).toEqual([target, other].sort());
+});🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/retention.test.ts` around lines 30 - 102, Add test coverage in
retention.test.ts by extending seedUser or the relevant test setup to create
rows in comments, rules, audit_log, moderation_actions, and consents, and add
assertions that purgeUserById removes only the purged user’s child rows while
preserving other users’ channels and sessions. Assert that the purged user’s
consent row remains intact, and add consents to the setupTestDb truncation list
so tests remain isolated.
Source: Coding guidelines
| test('retention helpers agree on the boundary', async () => { | ||
| expect(isRetentionExpired(new Date(Date.now() - 181 * DAY_MS).toISOString())).toBe(true); | ||
| expect(isRetentionExpired(new Date(Date.now() - 10 * DAY_MS).toISOString())).toBe(false); | ||
| expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Capture the reference time once; this assertion can flake.
Line 114 calls Date.now() twice. If the clock advances by one millisecond between the two calls, the two ISO strings differ and the test fails.
💚 Proposed fix
- expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString());
+ const now = Date.now();
+ expect(retentionCutoffIso(now)).toBe(new Date(now - 180 * DAY_MS).toISOString());📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString()); | |
| const now = Date.now(); | |
| expect(retentionCutoffIso(now)).toBe(new Date(now - 180 * DAY_MS).toISOString()); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/retention.test.ts` at line 114, Update the retentionCutoffIso
assertion to capture Date.now() once in a local reference-time variable, then
use that same value for both the function input and expected Date calculation.
Keep the existing 180-day cutoff expectation unchanged.
| import { db } from '$lib/server/db'; | ||
| import { auditLog, channels, comments, moderationActions, rules, sessions, users } from '$lib/server/db/schema'; | ||
|
|
||
| export const RETENTION_MS = 180 * 24 * 60 * 60 * 1000; // 180 days ≈ 6 months |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Locate public copy that states the account-deletion retention window.
set -euo pipefail
rg -n -C3 -i '6[- ]month|six month|180 day' src --iglob '*.{ts,svelte,md}'Repository: Bonobo791/Moderaty
Length of output: 9232
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- retention implementation ---'
cat -n src/lib/server/retention.ts | sed -n '1,180p'
printf '%s\n' '--- retention references and tests ---'
rg -n -C4 'RETENTION_MS|retentionCutoffIso|isRetentionExpired|deletedAt|6-month|6 months|180 days' src AGENTS.md --glob '!**/.svelte-kit/**' --glob '!**/build/**'
printf '%s\n' '--- relevant test context ---'
cat -n src/routes/api/cron/cron.test.ts | sed -n '130,180p'
printf '%s\n' '--- AGENTS retention context ---'
cat -n AGENTS.md | sed -n '165,190p'Repository: Bonobo791/Moderaty
Length of output: 49591
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from datetime import datetime, timedelta, timezone
for start in (datetime(2025, 1, 1, tzinfo=timezone.utc),
datetime(2025, 3, 1, tzinfo=timezone.utc),
datetime(2025, 7, 1, tzinfo=timezone.utc)):
six_month_boundary = start.replace(
year=start.year + (start.month + 5) // 12,
month=(start.month + 5) % 12 + 1,
)
print(
start.date(),
"calendar-six-month-days=",
(six_month_boundary - start).days,
"180-day-boundary=",
(start + timedelta(days=180)).date(),
"calendar-boundary=",
six_month_boundary.date(),
)
PY
printf '%s\n' '--- account-retention wording outside the initial source search ---'
rg -n -C3 -i 'account.{0,30}(retention|kept|purge|erase)|retention.{0,30}account|deleted.{0,30}(month|day)|month.{0,30}(delete|account|record)' . \
--glob '!**/.svelte-kit/**' --glob '!**/build/**' --glob '!node_modules/**'Repository: Bonobo791/Moderaty
Length of output: 13546
Use a true six-calendar-month account-retention cutoff. RETENTION_MS purges accounts after 180 days, but the dashboard and project documentation promise six months. Six calendar months can last 181–184 days. Calculate the cutoff by calendar months, or change the account-deletion copy to “180 days.” Do not change statutory-log references, which describe different data.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/retention.ts` at line 27, Replace the fixed-day RETENTION_MS
cutoff with a true six-calendar-month calculation for account retention, and
update the associated account-deletion behavior or copy to use that cutoff. Keep
statutory-log retention references unchanged, since they describe separate data.
Source: Coding guidelines
| export async function purgeUserById(userId: string, expectedDeletedAt?: string): Promise<boolean> { | ||
| return await db.transaction(async (tx) => { | ||
| const user = await tx.select({ deletedAt: users.deletedAt }).from(users).where(eq(users.id, userId)).get(); | ||
| if (!user?.deletedAt) return false; | ||
| if (expectedDeletedAt !== undefined && user.deletedAt !== expectedDeletedAt) return false; | ||
| const chs = await tx.select({ id: channels.id }).from(channels).where(eq(channels.userId, userId)).all(); | ||
| const channelIds = chs.map((ch) => ch.id); | ||
| if (channelIds.length) { | ||
| await tx.delete(moderationActions).where(inArray(moderationActions.channelId, channelIds)); | ||
| await tx.delete(comments).where(inArray(comments.channelId, channelIds)); | ||
| await tx.delete(auditLog).where(inArray(auditLog.channelId, channelIds)); | ||
| await tx.delete(rules).where(inArray(rules.channelId, channelIds)); | ||
| } | ||
| await tx.delete(channels).where(eq(channels.userId, userId)); | ||
| await tx.delete(sessions).where(eq(sessions.userId, userId)); | ||
| await tx | ||
| .update(users) | ||
| .set({ googleSub: `deleted:${userId}`, email: '[deleted]', displayName: '[deleted]', deletedAt: null }) | ||
| .where(eq(users.id, userId)); | ||
| return true; | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Trace DRY_RUN enforcement around the retention purge call sites.
set -euo pipefail
rg -n -C6 'purgeUserById|purgeExpiredUser' src
echo "== DRY_RUN reads =="
rg -n -C4 'DRY_RUN' srcRepository: Bonobo791/Moderaty
Length of output: 31805
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== retention imports and purge helper =="
sed -n '1,115p' src/lib/server/retention.ts
echo "== Google callback around retention handling =="
sed -n '70,125p' 'src/routes/api/auth/google/login/callback/+server.ts'
echo "== callback environment imports and writes =="
sed -n '1,35p' 'src/routes/api/auth/google/login/callback/+server.ts'
rg -n -C4 'insert\\(users\\)|update\\(users\\)|insert\\(consents\\)|purgeUserById|DRY_RUN|env' 'src/routes/api/auth/google/login/callback/+server.ts'Repository: Bonobo791/Moderaty
Length of output: 10711
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== pending consent handling and user creation =="
rg -n -C8 'kind: .new.|pending.*new|insert\\(users\\)|googleSub|parkPendingConsent' src/routes src/lib/server \
-g '*.ts' -g '*.svelte'Repository: Bonobo791/Moderaty
Length of output: 50374
Guard all retention purge paths during dry runs.
purgeUserById performs durable deletes and an anonymizing update without checking DRY_RUN. The Google login callback calls it directly, so an expired account can be purged during a dry run even though the cron route skips purgeExpiredUser.
Read DRY_RUN through $env/dynamic/private. If the purge is skipped, do not continue the callback as a fresh signup while the existing tombstone still owns the Google identity.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/retention.ts` around lines 62 - 83, Guard purgeUserById with
DRY_RUN from $env/dynamic/private so it performs no deletes or anonymizing
updates during dry runs. Update the Google login callback’s purge path to
recognize a skipped purge and stop instead of treating the tombstoned account as
a fresh signup while its Google identity remains claimed.
Source: Coding guidelines
| import { isRetentionExpired, purgeUserById } from '$lib/server/retention'; | ||
| import { createSession, SESSION_COOKIE } from '$lib/server/session'; | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Wrap the sign-in-time purge in error handling.
Line 95 calls purgeUserById(user.id) without a try/catch. The cron endpoint catches the equivalent purge failure, logs it, and continues (src/routes/api/cron/+server.ts lines 70-75). This file has no such handling: if the purge write fails, the exception propagates uncaught out of the login callback, and the user gets no clear message — unlike every other failure branch in this function, which returns error(502, '...') with a specific reason.
Wrap the purge call and fail with the same explicit error pattern already used in this file.
🐛 Proposed fix
if (user?.deletedAt && isRetentionExpired(user.deletedAt)) {
console.info(`account ${user.id} past the retention window; purging at sign-in`);
- await purgeUserById(user.id);
+ try {
+ await purgeUserById(user.id);
+ } catch (cause) {
+ console.error(`retention purge at sign-in failed for ${user.id}:`, cause);
+ throw error(502, 'Sign-in failed — please retry');
+ }
user = undefined;
}Also applies to: 89-97, 119-128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/api/auth/google/login/callback/`+server.ts around lines 28 - 30,
Wrap the sign-in-time purge call in the login callback, specifically the flow
around purgeUserById(user.id), in try/catch handling. On failure, return the
same explicit error(502, '...') pattern and wording style used by the other
failure branches in this function, rather than allowing the exception to escape;
preserve the existing successful login flow.
| const user = await db | ||
| .select({ id: users.id, deletedAt: users.deletedAt }) | ||
| .from(users) | ||
| .where(eq(users.id, pending.userId)) | ||
| .get(); | ||
| if (!user) return fail(400, { error: 'Your sign-in session expired — please sign in again.' }); | ||
| session = await db.transaction(async (tx) => { | ||
| await tx.insert(consents).values(consentRecord(user.id)); | ||
| // The login callback leaves a soft-deleted account pending while | ||
| // it is parked here; completing re-acceptance is what cancels | ||
| // the deletion — atomically with the consent record and session. | ||
| if (user.deletedAt) { | ||
| await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id)); | ||
| console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Re-check the retention cutoff inside the transaction before restoring a soft-deleted account.
This branch restores a soft-deleted account (clears deletedAt) without calling isRetentionExpired, unlike src/routes/api/auth/google/login/callback/+server.ts (lines 93-97), which enforces the same cutoff before allowing a restore. If the account crosses the retention boundary between the login callback's earlier check and this action running, it gets restored here instead of purged — defeating the documented 6-month retention guarantee.
The user row is also read once, before the transaction starts, and its deletedAt value is used unchanged inside the transaction. If a concurrent cron purge anonymizes this same user in between (setting google_sub = 'deleted:<id>', wiping email/display name, clearing deletedAt), this code still inserts a consent row and creates a live session bound to the now-tombstoned identity, because it never re-reads the row inside the transaction.
Move the lookup inside the transaction and reject with isRetentionExpired, matching the callback's pattern.
🐛 Proposed fix
+import { isRetentionExpired } from '$lib/server/retention';
...
} else {
- const user = await db
- .select({ id: users.id, deletedAt: users.deletedAt })
- .from(users)
- .where(eq(users.id, pending.userId))
- .get();
- if (!user) return fail(400, { error: 'Your sign-in session expired — please sign in again.' });
- session = await db.transaction(async (tx) => {
- await tx.insert(consents).values(consentRecord(user.id));
- // The login callback leaves a soft-deleted account pending while
- // it is parked here; completing re-acceptance is what cancels
- // the deletion — atomically with the consent record and session.
- if (user.deletedAt) {
- await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
- console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`);
- }
- return createSession(user.id, tx);
- });
- userId = user.id;
+ const outcome = await db.transaction(async (tx) => {
+ // Re-read inside the transaction so a concurrent retention purge
+ // cannot be missed between the check and the write (TOCTOU).
+ const user = await tx
+ .select({ id: users.id, deletedAt: users.deletedAt })
+ .from(users)
+ .where(eq(users.id, pending.userId))
+ .get();
+ if (!user || (user.deletedAt && isRetentionExpired(user.deletedAt))) return null;
+ await tx.insert(consents).values(consentRecord(user.id));
+ if (user.deletedAt) {
+ await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
+ console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`);
+ }
+ return { id: user.id, session: await createSession(user.id, tx) };
+ });
+ if (!outcome) return fail(400, { error: 'Your sign-in session expired — please sign in again.' });
+ userId = outcome.id;
+ session = outcome.session;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/consent/`+page.server.ts around lines 115 - 129, Move the users
lookup into the db.transaction callback, re-read the current row there, and use
that row for both consent insertion and restoration. Before restoring a
soft-deleted account, call isRetentionExpired with the transaction-fresh
deletion timestamp and reject expired accounts using the callback’s existing
retention handling. Ensure concurrent purges cannot lead to consent insertion or
session creation for a tombstoned user.




What
Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:
users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.deletedAtin the login callback. Channels stayactive=0until the user re-enables them — moderation never resumes silently.DRY_RUN=true.deleted:<id>,[deleted]) rather than deleted, keepingconsents.userIdvalid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.Migration
0008 adds nullable
users.deleted_at(I7 expand-migrate-contract). After merge: runnpm run db:migratefrom the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.Notes
LEGAL_VERSIONmust be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.Verification
npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)npm run check: 0 errors, 0 warningsnpm run build: green (adapter-netlify)