Skip to content

feat: soft deletes, lastActiveAt tracking, and admin trail search#2371

Merged
andrew-bierman merged 11 commits into
developmentfrom
claude/soft-deletes-user-activity-t3jJW
May 1, 2026
Merged

feat: soft deletes, lastActiveAt tracking, and admin trail search#2371
andrew-bierman merged 11 commits into
developmentfrom
claude/soft-deletes-user-activity-t3jJW

Conversation

@andrew-bierman
Copy link
Copy Markdown
Collaborator

@andrew-bierman andrew-bierman commented May 1, 2026

Summary

  • Soft deletes everywhere: adds deleted_at timestamp to all user-content tables (packs, pack_items, pack_templates, pack_template_items, trips, trail_condition_reports, posts, post_comments). Existing deleted: boolean columns are kept for backward compat; already-deleted rows are backfilled from updated_at.
  • User soft delete: DELETE /admin/users/:id now soft-deletes (sets deleted_at) instead of hard-deleting. New DELETE /admin/users/:id/hard endpoint for GDPR-style erasure — requires a reason body field, logs to console. New POST /admin/users/:id/restore to undo a soft delete.
  • lastActiveAt tracking: adds last_active_at to the users table. The authPlugin middleware updates it on every authenticated request, rate-limited to once per 5 minutes per user (fire-and-forget, non-blocking).
  • Admin list endpoints: users-list, packs-list, catalog-list now return { data, total, limit, offset } instead of flat arrays, expose lastActiveAt/deletedAt fields, and accept ?includeDeleted=true so admins can see soft-deleted records.
  • DAU/WAU/MAU: new GET /admin/analytics/platform/active-users endpoint.
  • Admin trail search: new GET /admin/trails/search endpoint (admin JWT, no user session needed) searches OSM routes by name + optional sport. Plus admin-auth versions of /:osmId and /:osmId/geometry. Also GET /admin/trails/conditions to list all trail condition reports with pagination.
  • Admin UI: users and packs pages show deleted rows dimmed with restore buttons and "Show deleted" toggles. Trails page redesigned with search-then-view flow and a Condition Reports tab.

Migration (0038)

ALTER TABLE users   ADD COLUMN last_active_at timestamp;
ALTER TABLE users   ADD COLUMN deleted_at timestamp;
ALTER TABLE packs          ADD COLUMN deleted_at timestamp;
ALTER TABLE pack_items     ADD COLUMN deleted_at timestamp;
ALTER TABLE pack_templates ADD COLUMN deleted_at timestamp;
ALTER TABLE pack_template_items ADD COLUMN deleted_at timestamp;
ALTER TABLE trips           ADD COLUMN deleted_at timestamp;
ALTER TABLE trail_condition_reports ADD COLUMN deleted_at timestamp;
ALTER TABLE posts           ADD COLUMN deleted_at timestamp;
ALTER TABLE post_comments   ADD COLUMN deleted_at timestamp;
-- + backfill + indexes

Test plan

  • Run migration on staging and verify columns exist
  • Soft-delete a user via admin → confirm deleted_at is set, user absent from default list
  • Restore the user → confirm deleted_at cleared
  • Hard-delete a user → confirm row gone, reason logged
  • Log in on mobile → wait 5 min → log in again → confirm last_active_at updated
  • Check GET /admin/analytics/platform/active-users returns DAU/WAU/MAU
  • Admin trails page: search "trail", click result → map loads
  • Admin trails → Condition Reports tab shows reports with delete action

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X

Summary by CodeRabbit

Release Notes

  • New Features

    • Added soft-delete and restore functionality for users, packs, and resources.
    • Introduced trail search and condition report management.
    • Added user activity tracking with last-active timestamps.
    • Enabled pagination across users, packs, and catalog lists with total counts.
    • Added filters to show/hide deleted items in admin dashboards.
  • Refactor

    • Updated API layer with improved error handling and client consistency.
  • Chores

    • Database schema updates supporting soft-deletion and activity tracking.

claude added 2 commits May 1, 2026 06:59
Schema changes (migration 0038):
- Add `last_active_at` and `deleted_at` to `users` table
- Add `deleted_at` timestamp alongside existing `deleted: boolean` on packs,
  pack_items, pack_templates, pack_template_items, trips, trail_condition_reports
  (backfills deleted_at from updated_at for already-soft-deleted rows)
- Add `deleted_at` to posts and post_comments for social feed soft deletes
- Indexes: users_last_active_at_idx, users_deleted_at_idx, posts_deleted_at_idx,
  users_active_last_active_idx

Auth middleware:
- Touch `last_active_at` on every authenticated request, rate-limited to once
  per 5 minutes per user (fire-and-forget, does not block the request)

Admin API (packages/api/src/routes/admin/index.ts):
- Users DELETE now soft-deletes (sets deleted_at) instead of hard-deleting
- New DELETE /admin/users/:id/hard — compliance hard delete, requires `reason`
  body field; cascades all user data via FK; logs reason to console
- New POST /admin/users/:id/restore — clears deleted_at to un-delete a user
- Users list: returns paginated { data, total, limit, offset }, adds
  lastActiveAt + deletedAt fields, supports ?includeDeleted=true
- Packs list: same paginated format, adds deleted/deletedAt fields, supports
  ?includeDeleted=true so admins can see soft-deleted packs
- Catalog list: same paginated format
- Stats: exclude soft-deleted users from user count
- Packs soft-delete now sets both deleted=true AND deleted_at=now()
- Search on nullable firstName/lastName uses sql template (handles NULL correctly)

Analytics (platform.ts):
- Growth query excludes soft-deleted users
- New GET /admin/analytics/platform/active-users — returns DAU/WAU/MAU based
  on last_active_at

Admin frontend (apps/admin):
- Updated api.ts types: AdminUser gains lastActiveAt/deletedAt; AdminPack gains
  deleted/deletedAt; PaginatedResponse<T> interface added; hardDeleteUser and
  restoreUser functions added; getCatalogItems returns paginated type
- Users page: extracts .data from paginated response, shows Last Active column,
  shows deleted badge + restore button for soft-deleted users, "Show deleted"
  checkbox, displays "X of Y users" total
- Packs page: same paginated handling, shows deleted rows dimmed with deletion
  date, "Show deleted" checkbox
- Catalog page: same paginated handling, shows "X of Y items" total

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
API (packages/api/src/routes/admin/trails.ts):
- GET /admin/trails/search — text search over osm_routes by name, optional
  sport filter, returns osmId/name/sport/network/distance/difficulty/bbox +
  hasMore pagination flag; uses admin JWT (no user JWT required)
- GET /admin/trails/:osmId/geometry — full GeoJSON geometry via admin auth,
  falls back to ST_LineMerge stitching when geometry is NULL
- GET /admin/trails/:osmId — lightweight metadata without geometry
- GET /admin/trails/conditions — paginated list of all trail condition reports
  across all users, joined with user email, supports ?q and ?includeDeleted
- DELETE /admin/trails/conditions/:reportId — soft-deletes a report
  (sets deleted=true + deleted_at)
- Mounted on existing adminRoutes (inherits admin JWT auth guard)

Admin frontend (apps/admin):
- api.ts: added searchTrails, getAdminTrail, getTrailConditions,
  deleteTrailCondition; updated getTrailGeometry to use adminFetch (admin JWT)
  instead of the old separate trailsFetch; added TrailSearchResult and
  TrailConditionReport types
- queryKeys.ts: added osm.search and osm.conditions keys
- trails/page.tsx: full redesign with two tabs:
  - "Trail Search" — search form with name + sport filter, results table
    showing osmId/name/sport/distance/difficulty, click any row to load in
    the map viewer below; handles 503 (OSM DB not configured) gracefully
  - "Condition Reports" — paginated table of all reports with trailName,
    surface, condition badge, reporter email, date, and soft-delete action

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
Copilot AI review requested due to automatic review settings May 1, 2026 12:01
@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

cloudflare-workers-and-pages Bot commented May 1, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
packrat-admin 955ffc2 Commit Preview URL

Branch Preview URL
May 01 2026, 07:01 PM

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 1, 2026

Warning

Rate limit exceeded

@andrew-bierman has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 52 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 42733053-e12d-4129-8635-5c6edf5a5400

📥 Commits

Reviewing files that changed from the base of the PR and between ddba1cc and 66b93be.

📒 Files selected for processing (20)
  • apps/admin/app/dashboard/packs/page.tsx
  • apps/admin/app/dashboard/trails/page.tsx
  • apps/admin/app/dashboard/users/page.tsx
  • apps/admin/lib/api.ts
  • apps/expo/app/(app)/_layout.tsx
  • apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
  • apps/expo/features/catalog/screens/CatalogItemsScreen.tsx
  • apps/expo/features/trips/components/TripForm.tsx
  • apps/expo/features/trips/screens/TripDetailScreen.tsx
  • apps/expo/features/trips/screens/TripListScreen.tsx
  • apps/expo/lib/testIds.ts
  • packages/api/src/db/seed-e2e-user.ts
  • packages/api/src/middleware/auth.ts
  • packages/api/src/routes/admin/analytics/catalog.ts
  • packages/api/src/routes/admin/analytics/platform.ts
  • packages/api/src/routes/admin/index.ts
  • packages/api/src/routes/admin/trails.ts
  • packages/api/src/routes/trails/index.ts
  • packages/api/src/schemas/admin.ts
  • packages/api/src/services/trails.ts

Walkthrough

This PR adds soft-delete capabilities with restore functionality for users and packs, refactors list endpoints to return paginated responses with total counts, significantly expands trail management with search/geometry/condition features, introduces lastActiveAt user activity tracking, and migrates the admin API client from manual fetch wrappers to a Treaty-based typed client.

Changes

Cohort / File(s) Summary
Admin Dashboard Components
apps/admin/app/dashboard/catalog/page.tsx, apps/admin/app/dashboard/packs/page.tsx, apps/admin/app/dashboard/users/page.tsx, apps/admin/app/dashboard/page.tsx
Updated to consume paginated API responses with data/total shape; added soft-delete UI with conditionally rendered "Deleted" labels and restore buttons; packs and users now support includeDeleted toggling; summary text updated to show "X of Y" with plural-aware counts.
Trail Management Page
apps/admin/app/dashboard/trails/page.tsx
Refactored from simple OSM relation viewer to tabbed "Trails" interface with (1) search tab querying trails by text/sport, rendering results in selectable table, (2) dedicated geometry viewer fetching via osmId, and (3) "Trail Condition Reports" tab with paginated filterable report listing, per-row condition styling, and soft-delete mutations.
Admin API Client & Types
apps/admin/lib/api.ts
Replaced manual fetch helpers with Treaty-based typed adminClient and adminFetcher; added centralized error handling; updated data models (AdminUser gains lastActiveAt/deletedAt, AdminPack gains deleted/deletedAt); changed list endpoints to return PaginatedResponse<T> instead of arrays; added user restore/hard-delete, trail search/geometry/condition APIs, and AnalyticsPeriod type with range parameter support.
Admin Query Keys
apps/admin/lib/queryKeys.ts
Extended queryKeys.osm with search(q, sport?) and conditions(q?) key generators for trail queries.
Backend Admin Routes
packages/api/src/routes/admin/index.ts
Updated list endpoints (/users-list, /packs-list, /catalog-list) to return { data, total, limit, offset }; added includeDeleted query support; replaced unconditional user deletion with soft-delete (DELETE /users/:id), hard-delete (DELETE /users/:id/hard with reason logging), and restore (POST /users/:id/restore); expanded selected fields to include soft-delete metadata; excluded soft-deleted users from /stats count.
Backend Trail Routes
packages/api/src/routes/admin/trails.ts
New file introducing adminTrailsRoutes with /search (text/sport filtering with pagination), /:osmId/geometry (GeoJSON geometry fetching with dynamic stitching), /:osmId (metadata + bbox), /conditions (paginated condition reports with optional soft-delete inclusion), and /:reportId (soft-delete condition report).
User Activity & Auth
packages/api/src/middleware/auth.ts
Auth macro now performs fire-and-forget update of lastActiveAt once per user per 5 minutes on JWT verification; errors silently ignored.
Analytics Routes
packages/api/src/routes/admin/analytics/platform.ts
Added new /active-users endpoint returning { dau, wau, mau } (day/week/month active user counts); updated /growth to exclude soft-deleted users.
Database Schema
packages/api/src/db/schema.ts
Added lastActiveAt and deletedAt columns to users; added deletedAt to packs, pack_items, pack_templates, pack_template_items, trail_condition_reports, trips, posts, and post_comments.
Database Migrations
packages/api/drizzle/0039_worried_wrecking_crew.sql
Migration adds deleted_at nullable timestamp columns across multiple tables and last_active_at to users.
Database Snapshots
packages/api/drizzle/meta/0037_snapshot.json, packages/api/drizzle/meta/0038_snapshot.json, packages/api/drizzle/meta/0039_snapshot.json, packages/api/drizzle/meta/_journal.json
Schema snapshots updated to reflect soft-delete columns, vector embedding indexes, unique constraints on likes/comments, and posts/comments social domain tables; journal entries added for new migrations.
Test Fixtures
packages/api/src/utils/__tests__/compute-pack.test.ts, packages/api/test/packs.test.ts
Updated pack factory/mock objects to include deletedAt: null matching new schema shape.

Sequence Diagram(s)

sequenceDiagram
    participant User as Admin User
    participant UI as Trails UI<br/>(Search Tab)
    participant API as Admin API<br/>(trails/search)
    participant DB as PostgreSQL<br/>(osm_routes)

    User->>UI: Enter search query & optional sport filter
    activate UI
    UI->>API: POST /trails/search {q, sport?, limit, offset}
    activate API
    API->>DB: SELECT * FROM osm_routes<br/>WHERE name ILIKE '%q%'<br/>AND (sport = ? OR NULL)<br/>LIMIT/OFFSET
    activate DB
    DB-->>API: Trail rows with bbox envelope
    deactivate DB
    API->>API: Parse/normalize GeoJSON bbox,<br/>compute hasMore, pagination
    API-->>UI: {trails[], hasMore, offset, limit}
    deactivate API
    UI->>UI: Render table with selectable rows
    deactivate UI
    
    rect rgba(100, 150, 200, 0.5)
        Note over User,DB: User clicks row to view geometry
        User->>UI: Click trail row
        activate UI
        UI->>API: GET /trails/:osmId/geometry
        activate API
        API->>DB: SELECT geometry/members<br/>FROM osm_routes WHERE osm_id = ?
        activate DB
        DB-->>API: GeoJSON geometry or members array
        deactivate DB
        alt geometry is NULL
            API->>API: Import stitchRouteGeometry(),<br/>build geometry from members
        end
        API-->>UI: {geometry: GeoJSON, bbox, ...}
        deactivate API
        UI->>UI: Render on map, update<br/>geometry container
        deactivate UI
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Suggested reviewers

  • mikib0
  • Isthisanmol
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the three main changes: soft deletes, last-active tracking, and admin trail search. It is concise, specific, and directly reflects the primary objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/soft-deletes-user-activity-t3jJW

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 40 minutes and 52 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file api mobile web database labels May 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Coverage Report for API Unit Tests Coverage (./packages/api)

Status Category Percentage Covered / Total
🔵 Lines 72.93% 609 / 835
🔵 Statements 72.93% (🎯 65%) 609 / 835
🔵 Functions 96% 48 / 50
🔵 Branches 88.27% 271 / 307
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/api/src/services/trails.ts 0% 100% 100% 0% 2-61
Generated in workflow #1032 for commit 66b93be by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Coverage Report for Expo Unit Tests Coverage (./apps/expo)

Status Category Percentage Covered / Total
🔵 Lines 81.7% 536 / 656
🔵 Statements 81.7% (🎯 75%) 536 / 656
🔵 Functions 92.98% 53 / 57
🔵 Branches 89.73% 201 / 224
File CoverageNo changed files found.
Generated in workflow #1032 for commit 66b93be by the Vitest Coverage Report Action

@andrew-bierman andrew-bierman changed the base branch from main to development May 1, 2026 12:01
@andrew-bierman andrew-bierman changed the title feat: OSM trail data layer POC feat: soft deletes, lastActiveAt tracking, and admin trail search May 1, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an admin-facing POC for browsing OpenStreetMap-backed trail data, while also introducing soft-delete + activity tracking infrastructure to support admin workflows and platform analytics.

Changes:

  • Adds new /api/admin/trails/* routes for OSM trail search, metadata, geometry, and trail condition report moderation.
  • Updates admin list endpoints (users/packs/catalog) to return paginated responses and adds soft-delete / restore / hard-delete controls.
  • Introduces last_active_at tracking (updated by auth middleware) and deleted_at columns + indexes via a new DB migration.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
packages/api/src/routes/admin/trails.ts New admin trails + trail-condition moderation endpoints backed by OSM DB + main DB
packages/api/src/routes/admin/index.ts Paginated admin list responses; soft-delete/restore/hard-delete users; deletedAt support for packs
packages/api/src/routes/admin/analytics/platform.ts Excludes deleted users in growth; adds DAU/WAU/MAU endpoint based on lastActiveAt
packages/api/src/middleware/auth.ts Updates users’ last_active_at (throttled) on authenticated requests
packages/api/src/db/schema.ts Adds lastActiveAt / deletedAt columns across multiple tables
packages/api/drizzle/meta/_journal.json Registers migration 0038
packages/api/drizzle/0038_soft_deletes_and_last_active.sql Migration adding columns + backfills + indexes for lastActiveAt/deletedAt
apps/admin/lib/queryKeys.ts Adds OSM trail query keys
apps/admin/lib/api.ts Switches admin list fetches to paginated responses; adds admin trails + delete/restore APIs
apps/admin/app/dashboard/users/page.tsx Shows deleted/last-active status; adds “show deleted” + restore actions
apps/admin/app/dashboard/trails/page.tsx Adds trail search + viewer UI and trail condition report moderation tab
apps/admin/app/dashboard/packs/page.tsx Shows deleted status; adds “show deleted”; handles paginated response
apps/admin/app/dashboard/catalog/page.tsx Handles paginated response for catalog list

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +288 to +323
const [packsList, [totalRow]] = await Promise.all([
db
.select({
id: packs.id,
name: packs.name,
description: packs.description,
category: packs.category,
isPublic: packs.isPublic,
deleted: packs.deleted,
deletedAt: packs.deletedAt,
createdAt: packs.createdAt,
userEmail: users.email,
})
.from(packs)
.leftJoin(users, eq(packs.userId, users.id))
.where(whereClause)
.orderBy(desc(packs.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: count() })
.from(packs)
.leftJoin(users, eq(packs.userId, users.id))
.where(whereClause),
]);

return {
data: packsList.map((p) => ({
...p,
createdAt: p.createdAt?.toISOString() ?? null,
deletedAt: p.deletedAt?.toISOString() ?? null,
})),
total: totalRow?.count ?? 0,
limit,
offset,
};
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/admin/packs-list now returns a paginated object { data, total, limit, offset } instead of the previous array response. This will break existing consumers/tests (e.g. packages/api/test/admin.test.ts asserts the response is an array). Update the tests and any remaining call sites, or preserve the old response shape for backwards compatibility.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +59
const { mutateAsync: handleRestore } = useMutation({
mutationFn: () => restoreUser(user.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.admin.users() });
},
});
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invalidateQueries key here uses queryKeys.admin.users() (which expands to ['admin','users', undefined]), but the list query is registered under queryKeys.admin.users(q) (and should also include includeDeleted). This means deletes/restores may not invalidate the currently displayed list (especially when q is set). Prefer invalidating via a stable prefix key like ['admin','users'], and include includeDeleted in the queryKey so toggling it triggers a refetch.

Copilot uses AI. Check for mistakes.
Comment thread apps/admin/app/dashboard/users/page.tsx Outdated
isLoading,
isError,
} = useQuery({
queryKey: queryKeys.admin.users(q),
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includeDeleted is used by queryFn, but it is not included in the queryKey (queryKeys.admin.users(q)), so toggling the checkbox can keep showing cached results from the previous setting. Include includeDeleted in the query key (or refactor the query key helper to accept an object) so cache entries are separated and a toggle refetches.

Suggested change
queryKey: queryKeys.admin.users(q),
queryKey: [...queryKeys.admin.users(q), { includeDeleted }],

Copilot uses AI. Check for mistakes.
Comment thread apps/admin/app/dashboard/packs/page.tsx Outdated
isLoading,
isError,
} = useQuery({
queryKey: queryKeys.admin.packs(q),
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includeDeleted affects the query results, but it is not part of the queryKey (queryKeys.admin.packs(q)), so toggling the checkbox can keep serving cached data for the previous state. Include includeDeleted in the query key (or change the query key helper to accept an object) so React Query treats them as different caches.

Suggested change
queryKey: queryKeys.admin.packs(q),
queryKey: [...queryKeys.admin.packs(q), { includeDeleted }],

Copilot uses AI. Check for mistakes.
const { mutateAsync: handleDelete } = useMutation({
mutationFn: () => deleteTrailCondition(report.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.osm.conditions() });
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invalidateQueries key uses queryKeys.osm.conditions() (=> ['osm','conditions', undefined]), but the list query is keyed with queryKeys.osm.conditions(activeSearch) (=> ['osm','conditions','...']). This won't invalidate filtered lists, so the UI can keep showing the deleted row until a manual refresh. Invalidate using a prefix key like ['osm','conditions'] (or add a separate conditionsBase key helper).

Suggested change
queryClient.invalidateQueries({ queryKey: queryKeys.osm.conditions() });
queryClient.invalidateQueries({ queryKey: ['osm', 'conditions'] });

Copilot uses AI. Check for mistakes.
Comment thread packages/api/src/routes/admin/trails.ts Outdated
Comment on lines +4 to +5
import { and, count, desc, eq, ilike, isNull, or } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file imports isNull but never uses it, and also imports sql in a separate statement even though it’s already importing from drizzle-orm. With Biome's correctness.noUnusedImports enabled, this will fail lint. Remove the unused import and consolidate the drizzle-orm imports.

Suggested change
import { and, count, desc, eq, ilike, isNull, or } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm';

Copilot uses AI. Check for mistakes.
Comment on lines 28 to 52
const payload = await verifyJWT({ token });
if (!payload) return status(401, { error: 'Invalid token' });

const uid = Number(payload.userId);

// Fire-and-forget: update last_active_at at most once per 5 min per user
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
createDb()
.update(users)
.set({ lastActiveAt: new Date() })
.where(
and(
eq(users.id, uid),
or(isNull(users.lastActiveAt), lt(users.lastActiveAt, fiveMinutesAgo)),
),
)
.catch(() => {});

const { userId: _uid, role: _role, ...rest } = payload;
return {
user: {
userId: Number(payload.userId),
userId: uid,
role: (payload.role as 'USER' | 'ADMIN') ?? 'USER',
...rest,
} as AuthUser, // safe-cast: JWT payload validated by auth middleware — userId and role fields are confirmed present
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyJWT returns a cast payload without runtime validation, so Number(payload.userId) can become NaN (e.g., if the token payload shape ever changes or is malformed). Add an explicit Number.isFinite check and return 401 if invalid, to avoid issuing a WHERE id = NaN update and returning a bad userId to downstream handlers.

Copilot uses AI. Check for mistakes.
.set({ lastActiveAt: new Date() })
.where(
and(
eq(users.id, uid),
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fire-and-forget last_active_at update currently runs for any authenticated token, including users that have been soft-deleted. Consider adding isNull(users.deletedAt) to the update filter so deleted users don’t continue to accumulate activity timestamps (which can also distort DAU/WAU/MAU if filters are ever missed elsewhere).

Suggested change
eq(users.id, uid),
eq(users.id, uid),
isNull(users.deletedAt),

Copilot uses AI. Check for mistakes.
Comment thread packages/api/src/routes/admin/index.ts Outdated
Comment on lines +436 to +438
// Cascading FKs handle deletion of all related user data.
// Caller must supply a compliance reason for the audit log.
const deleted = await db.delete(users).where(eq(users.id, id)).returning({ id: users.id });
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims cascading FKs handle deletion of all related user data, but several tables reference users.id without onDelete: 'cascade' in the schema, and this handler already anticipates FK violations (23503). Please update the comment to reflect the actual behavior/constraints (or ensure cascades truly cover all related user data).

Copilot uses AI. Check for mistakes.
Comment thread apps/admin/lib/api.ts Outdated
Comment on lines 72 to 87
export function getUsers({
limit = 100,
offset = 0,
q,
includeDeleted = false,
}: {
limit?: number;
offset?: number;
q?: string;
} = {}): Promise<AdminUser[]> {
includeDeleted?: boolean;
} = {}): Promise<PaginatedResponse<AdminUser>> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
return adminFetch<AdminUser[]>(`/users-list?${params}`);
if (includeDeleted) params.set('includeDeleted', 'true');
return adminFetch<PaginatedResponse<AdminUser>>(`/users-list?${params}`);
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUsers/getPacks/getCatalogItems now return PaginatedResponse<T> instead of T[]. There are still call sites in the admin app that expect arrays (e.g. the dashboard overview page uses const { data: users = [] } = useQuery(...)). Those will break at runtime and/or typecheck; update all call sites to use result.data (and adjust query keys accordingly).

Copilot uses AI. Check for mistakes.
claude added 4 commits May 1, 2026 13:00
- Replace handwritten migration with drizzle-kit generated 0039; fix
  snapshot chain (0037→0038 stub→0039) to resolve prior journal/snapshot
  mismatch left by a deleted 0038
- Rewrite apps/admin/lib/api.ts to use Eden Treaty (treaty<App>) instead
  of raw adminFetch — eliminates manual type duplication and gives
  compile-time route/method/param validation
- Switch all template-literal className usages to cn() across packs,
  users, and trails dashboard pages
- Fix dashboard overview page to unwrap PaginatedResponse (.data) from
  getUsers/getPacks/getCatalogItems queries
- Fix useTemplate lint violations (string concat → template literals) in
  admin index route
- Fix .returning() calls to use no-arg form (Drizzle pg typing)
- Add deletedAt to test fixtures in compute-pack.test.ts and packs.test.ts

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
@github-actions github-actions Bot removed documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file mobile web labels May 1, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
packages/api/src/db/schema.ts (1)

25-38: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add indexes for the new user activity/deletion filters.

/admin/users-list, /admin/analytics/platform/growth, and /admin/analytics/platform/active-users now filter on deleted_at and last_active_at, but this table still defines no supporting indexes. On a non-trivial users table, those endpoints will degrade into full scans. A partial index on last_active_at for non-deleted users, plus an index or partial index covering deleted_at, would keep the new paths scalable. As per coding guidelines "Check for missing indexes on foreign key columns and on columns used in WHERE / ORDER BY clauses."

Suggested schema shape
-export const users = pgTable('users', {
-  id: serial('id').primaryKey(),
-  email: text('email').unique().notNull(),
-  emailVerified: boolean('email_verified').default(false),
-  passwordHash: text('password_hash'),
-  firstName: text('first_name'),
-  lastName: text('last_name'),
-  avatarUrl: text('avatar_url'),
-  role: text('role').default('USER'), // 'USER', 'ADMIN'
-  createdAt: timestamp('created_at').defaultNow(),
-  updatedAt: timestamp('updated_at').defaultNow(),
-  lastActiveAt: timestamp('last_active_at'),
-  deletedAt: timestamp('deleted_at'),
-});
+export const users = pgTable(
+  'users',
+  {
+    id: serial('id').primaryKey(),
+    email: text('email').unique().notNull(),
+    emailVerified: boolean('email_verified').default(false),
+    passwordHash: text('password_hash'),
+    firstName: text('first_name'),
+    lastName: text('last_name'),
+    avatarUrl: text('avatar_url'),
+    role: text('role').default('USER'), // 'USER', 'ADMIN'
+    createdAt: timestamp('created_at').defaultNow(),
+    updatedAt: timestamp('updated_at').defaultNow(),
+    lastActiveAt: timestamp('last_active_at'),
+    deletedAt: timestamp('deleted_at'),
+  },
+  (table) => ({
+    activeLastSeenIdx: index('users_active_last_active_idx')
+      .on(table.lastActiveAt)
+      .where(sql`${table.deletedAt} IS NULL AND ${table.lastActiveAt} IS NOT NULL`),
+    deletedAtIdx: index('users_deleted_at_idx').on(table.deletedAt),
+  }),
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/db/schema.ts` around lines 25 - 38, The users table schema
(users) lacks indexes for the new filters; add a partial index on last_active_at
for non-deleted users and an index (or partial index) on deleted_at to avoid
full table scans: create a partial index using last_active_at WHERE deleted_at
IS NULL (to support active-user queries) and add an index on deleted_at (or a
partial index for deleted_at IS NOT NULL/IS NULL depending on query patterns)
via a DB migration that targets the users table and these columns (lastActiveAt
/ deletedAt).
packages/api/src/routes/admin/analytics/platform.ts (1)

106-148: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exclude soft-deleted posts from /activity.

posts now has deletedAt, but this query still counts every post newer than startDate. Once a post is soft-deleted, it should stop contributing to platform activity; otherwise the chart stays inflated after moderation/admin deletions. Based on learnings "Implement soft deletes for all user content in the database".

Suggested fix
           db
             .select({
               date: sql<string>`date_trunc(${sql.raw(`'${period}'`)}, ${posts.createdAt})::date::text`,
               count: count(),
             })
             .from(posts)
-            .where(gte(posts.createdAt, startDate))
+            .where(and(isNull(posts.deletedAt), gte(posts.createdAt, startDate)))
             .groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${posts.createdAt})`)
             .orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${posts.createdAt})`),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/admin/analytics/platform.ts` around lines 106 - 148,
The /activity query is counting posts regardless of soft-deletes; update the
posts subquery in the GET '/activity' handler to exclude soft-deleted records by
adding a condition that posts.deletedAt IS NULL (or eq(posts.deletedAt, null) /
isNull(posts.deletedAt)) alongside the existing gte(posts.createdAt, startDate)
filter so postActivity stops counting soft-deleted posts; adjust the
posts.select/.where chain that builds postActivity (references: posts,
posts.createdAt, posts.deletedAt, postActivity, startDate, period, db) to
include this additional predicate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin/app/dashboard/packs/page.tsx`:
- Around line 113-120: The query key used by useQuery (queryKeys.admin.packs(q))
doesn’t include the includeDeleted flag so toggling the checkbox won’t
invalidate the cache; update the useQuery call that references
queryKeys.admin.packs and getPacks to include includeDeleted in the queryKey
(e.g., pass includeDeleted as part of the key or as a second arg to
queryKeys.admin.packs) so that useQuery's queryKey changes when the checkbox
toggles and the queryFn (getPacks({ q, includeDeleted })) refetches with the new
flag.

In `@apps/admin/app/dashboard/trails/page.tsx`:
- Around line 335-338: The component currently treats a failed
getTrailConditions call as an empty result because it only checks
result/isLoading; update the useQuery usage for queryKeys.osm.conditions to also
surface query errors by reading isError and error (or using onError) from
useQuery and rendering an explicit error state/message when isError is true
(instead of falling through to the "No trail condition reports found" UI);
ensure the same pattern is applied to the other block mentioned (lines 364-412)
so both uses of getTrailConditions/ useQuery report API/backend failures rather
than showing the empty-state.

In `@apps/admin/app/dashboard/users/page.tsx`:
- Around line 134-141: The query key for useQuery currently omits includeDeleted
so toggling the "Show deleted" checkbox doesn't trigger a refetch; update the
key passed to useQuery to include includeDeleted (e.g., use
queryKeys.admin.users(q, includeDeleted) or a tuple [queryKeys.admin.users(q),
includeDeleted]) so React Query treats different includeDeleted values as
distinct caches and will refetch via the existing queryFn which calls getUsers({
q, includeDeleted }).

In `@apps/admin/lib/api.ts`:
- Around line 336-338: TrailGeometry currently extends TrailSearchResult making
bbox required even though the backend route returns { osmId, name, sport,
network, distance, difficulty, description, geometry } with no bbox; update the
types so callers don’t receive an unsound type: either remove the inheritance
(change TrailGeometry to be a standalone interface with geometry: object | null)
or change the backend response to include bbox and update the route
(packages/api/src/routes/admin/trails.ts) to return bbox; also remove the unsafe
cast in getTrailGeometry() so the returned type accurately reflects the route
response.

In `@packages/api/drizzle/0039_worried_wrecking_crew.sql`:
- Around line 1-10: Add indexes on the new soft-delete and activity columns so
admin/analytics filters don't trigger table scans: create separate indexes for
deleted_at on pack_items, pack_template_items, pack_templates, packs,
trail_condition_reports, trips, users, posts, post_comments and for
last_active_at on users (use a migration that complements ALTER TABLE statements
shown in the diff); ensure index names are unique and deterministic (e.g.,
idx_<table>_deleted_at, idx_users_last_active_at) and add them in the same
migration series so they apply alongside the new columns.

In `@packages/api/src/middleware/auth.ts`:
- Around line 31-50: The auth resolution currently trusts the JWT payload and
returns a user based only on payload.userId; to prevent soft-deleted accounts
from being accepted you must query the users table and ensure users.deletedAt IS
NULL before returning the user object. In the middleware where payload and uid
are derived (payload.userId → uid), call createDb() to select the user row from
users by eq(users.id, uid) and check that deletedAt is null (or include
and(isNull(users.deletedAt), eq(users.id, uid)) in the where), and if no
non-deleted user is found return null/unauthorized instead of returning the
payload-derived user; keep the existing lastActiveAt update logic but base
authentication on the DB-verified user row.

In `@packages/api/src/routes/admin/index.ts`:
- Around line 440-443: The console.info call is logging admin-supplied
body.reason (sensitive PII/legal data); update the hard-delete flow around the
db.delete(...).returning() call so you no longer emit body.reason to
logs—replace the console.info(`[COMPLIANCE] Hard-deleted user ${id}. Reason:
${body.reason}`) with a redacted reference or code (e.g., a deletion audit ID or
fixed message) and write the full body.reason into a restricted audit
store/service (call your audit logger or AuditService) linked to that reference;
keep the returned response ({ success: true, purged: true }) unchanged.

In `@packages/api/src/routes/admin/trails.ts`:
- Around line 136-150: The members.ref field currently uses z.number(), which
loses precision for 64-bit OSM IDs; update the DetailRowSchema members
definition so ref is parsed as a precise type (e.g., parse as string and then
coerce to bigint using Zod: ref: z.string().pipe(z.coerce.bigint()) or ref:
z.coerce.bigint()) instead of z.number(), and ensure the parsed row.members
values (the array passed to stitchRouteGeometry) are the bigint (or string)
values expected by stitchRouteGeometry/DB so the ::bigint[] SQL cast receives
exact IDs.

---

Outside diff comments:
In `@packages/api/src/db/schema.ts`:
- Around line 25-38: The users table schema (users) lacks indexes for the new
filters; add a partial index on last_active_at for non-deleted users and an
index (or partial index) on deleted_at to avoid full table scans: create a
partial index using last_active_at WHERE deleted_at IS NULL (to support
active-user queries) and add an index on deleted_at (or a partial index for
deleted_at IS NOT NULL/IS NULL depending on query patterns) via a DB migration
that targets the users table and these columns (lastActiveAt / deletedAt).

In `@packages/api/src/routes/admin/analytics/platform.ts`:
- Around line 106-148: The /activity query is counting posts regardless of
soft-deletes; update the posts subquery in the GET '/activity' handler to
exclude soft-deleted records by adding a condition that posts.deletedAt IS NULL
(or eq(posts.deletedAt, null) / isNull(posts.deletedAt)) alongside the existing
gte(posts.createdAt, startDate) filter so postActivity stops counting
soft-deleted posts; adjust the posts.select/.where chain that builds
postActivity (references: posts, posts.createdAt, posts.deletedAt, postActivity,
startDate, period, db) to include this additional predicate.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6e393642-63cd-42c4-a62e-617b334b78e3

📥 Commits

Reviewing files that changed from the base of the PR and between cdeb103 and ddba1cc.

📒 Files selected for processing (20)
  • apps/admin/app/dashboard/catalog/page.tsx
  • apps/admin/app/dashboard/packs/page.tsx
  • apps/admin/app/dashboard/page.tsx
  • apps/admin/app/dashboard/trails/page.tsx
  • apps/admin/app/dashboard/users/page.tsx
  • apps/admin/lib/api.ts
  • apps/admin/lib/queryKeys.ts
  • packages/api/drizzle/0038_broad_winter_soldier.sql
  • packages/api/drizzle/0039_worried_wrecking_crew.sql
  • packages/api/drizzle/meta/0037_snapshot.json
  • packages/api/drizzle/meta/0038_snapshot.json
  • packages/api/drizzle/meta/0039_snapshot.json
  • packages/api/drizzle/meta/_journal.json
  • packages/api/src/db/schema.ts
  • packages/api/src/middleware/auth.ts
  • packages/api/src/routes/admin/analytics/platform.ts
  • packages/api/src/routes/admin/index.ts
  • packages/api/src/routes/admin/trails.ts
  • packages/api/src/utils/__tests__/compute-pack.test.ts
  • packages/api/test/packs.test.ts

Comment thread apps/admin/app/dashboard/packs/page.tsx
Comment on lines +335 to +338
const { data: result, isLoading } = useQuery({
queryKey: queryKeys.osm.conditions(activeSearch || undefined),
queryFn: () => getTrailConditions({ q: activeSearch || undefined }),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Surface trail-condition fetch failures instead of the empty state.

When getTrailConditions() fails, result stays undefined and this component falls through to No trail condition reports found. That hides backend/API outages as a legitimate empty result, which is misleading for moderation/admin work.

Suggested fix
-  const { data: result, isLoading } = useQuery({
+  const { data: result, isLoading, isError } = useQuery({
     queryKey: queryKeys.osm.conditions(activeSearch || undefined),
     queryFn: () => getTrailConditions({ q: activeSearch || undefined }),
   });
...
-      {isLoading ? (
+      {isError ? (
+        <p className="text-sm text-destructive">
+          Failed to load trail condition reports. Check that the API is reachable.
+        </p>
+      ) : isLoading ? (
         <p className="text-sm text-muted-foreground animate-pulse">Loading reports…</p>
       ) : (

Also applies to: 364-412

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/app/dashboard/trails/page.tsx` around lines 335 - 338, The
component currently treats a failed getTrailConditions call as an empty result
because it only checks result/isLoading; update the useQuery usage for
queryKeys.osm.conditions to also surface query errors by reading isError and
error (or using onError) from useQuery and rendering an explicit error
state/message when isError is true (instead of falling through to the "No trail
condition reports found" UI); ensure the same pattern is applied to the other
block mentioned (lines 364-412) so both uses of getTrailConditions/ useQuery
report API/backend failures rather than showing the empty-state.

Comment thread apps/admin/app/dashboard/users/page.tsx
Comment thread apps/admin/lib/api.ts Outdated
Comment on lines +1 to +10
ALTER TABLE "pack_items" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "pack_template_items" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "pack_templates" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "packs" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "trail_condition_reports" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "trips" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "last_active_at" timestamp;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
ALTER TABLE "post_comments" ADD COLUMN "deleted_at" timestamp; No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add indexes for the new soft-delete/activity filter columns.

These columns (deleted_at, last_active_at) are now central to admin list/analytics filters, but this migration only adds columns. Without indexes, those queries will degrade into table scans as data grows.

Suggested migration follow-up
 ALTER TABLE "pack_items" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint
 ...
 ALTER TABLE "post_comments" ADD COLUMN "deleted_at" timestamp;
+--> statement-breakpoint
+CREATE INDEX "idx_users_last_active_at" ON "users" ("last_active_at");
+CREATE INDEX "idx_users_deleted_at" ON "users" ("deleted_at");
+CREATE INDEX "idx_packs_deleted_at" ON "packs" ("deleted_at");
+CREATE INDEX "idx_pack_items_deleted_at" ON "pack_items" ("deleted_at");
+CREATE INDEX "idx_pack_templates_deleted_at" ON "pack_templates" ("deleted_at");
+CREATE INDEX "idx_pack_template_items_deleted_at" ON "pack_template_items" ("deleted_at");
+CREATE INDEX "idx_trips_deleted_at" ON "trips" ("deleted_at");
+CREATE INDEX "idx_trail_condition_reports_deleted_at" ON "trail_condition_reports" ("deleted_at");
+CREATE INDEX "idx_posts_deleted_at" ON "posts" ("deleted_at");
+CREATE INDEX "idx_post_comments_deleted_at" ON "post_comments" ("deleted_at");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/drizzle/0039_worried_wrecking_crew.sql` around lines 1 - 10, Add
indexes on the new soft-delete and activity columns so admin/analytics filters
don't trigger table scans: create separate indexes for deleted_at on pack_items,
pack_template_items, pack_templates, packs, trail_condition_reports, trips,
users, posts, post_comments and for last_active_at on users (use a migration
that complements ALTER TABLE statements shown in the diff); ensure index names
are unique and deterministic (e.g., idx_<table>_deleted_at,
idx_users_last_active_at) and add them in the same migration series so they
apply alongside the new columns.

Comment thread packages/api/src/middleware/auth.ts
Comment on lines 440 to +443
const deleted = await db.delete(users).where(eq(users.id, id)).returning();
if (!deleted.length) return status(404, { error: 'User not found' });
return { success: true as const };
console.info(`[COMPLIANCE] Hard-deleted user ${id}. Reason: ${body.reason}`);
return { success: true as const, purged: true as const };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't write the free-form erasure reason to worker logs.

body.reason is admin input and can include PII or legal context about the deletion request. Emitting it via console.info pushes sensitive compliance data into broad log-retention paths. Log a redacted code/reference here and persist the full reason in a restricted audit store instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/admin/index.ts` around lines 440 - 443, The
console.info call is logging admin-supplied body.reason (sensitive PII/legal
data); update the hard-delete flow around the db.delete(...).returning() call so
you no longer emit body.reason to logs—replace the console.info(`[COMPLIANCE]
Hard-deleted user ${id}. Reason: ${body.reason}`) with a redacted reference or
code (e.g., a deletion audit ID or fixed message) and write the full body.reason
into a restricted audit store/service (call your audit logger or AuditService)
linked to that reference; keep the returned response ({ success: true, purged:
true }) unchanged.

Comment thread packages/api/src/routes/admin/trails.ts
claude and others added 2 commits May 1, 2026 13:43
Add packages/api/src/schemas/admin.ts as single source of truth for all
admin response shapes (TypeBox, for Eden Treaty compatibility). Spread
`response: { 200: Schema, ...AdminErrorResponses }` onto every admin route
so Eden Treaty infers the 200 body type precisely — removing the need for
`as unknown as T` casts in apps/admin/lib/api.ts.

Error schemas use `t.Unsafe<any>(...)` to preserve OpenAPI JSON Schema while
sidestepping ElysiaCustomStatusResponse's invariant T parameter, which would
otherwise reject literal-typed error bodies.

Frontend types in api.ts are now derived from the shared schemas via
`Static<typeof Schema>` rather than hand-declared interfaces.

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

cloudflare-workers-and-pages Bot commented May 1, 2026

Deploying packrat-guides with  Cloudflare Pages  Cloudflare Pages

Latest commit: b20b465
Status: ✅  Deploy successful!
Preview URL: https://c7d60c92.packrat-guides-6gq.pages.dev
Branch Preview URL: https://claude-soft-deletes-user-act.packrat-guides-6gq.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

Deploying packrat-landing with  Cloudflare Pages  Cloudflare Pages

Latest commit: b20b465
Status: ✅  Deploy successful!
Preview URL: https://9bf918ad.packrat-landing.pages.dev
Branch Preview URL: https://claude-soft-deletes-user-act.packrat-landing.pages.dev

View logs

claude added 2 commits May 1, 2026 18:52
- Fix TestIds → testIds in profile and TripForm (was causing bun check-types failure)
- Add includeDeleted to users/packs query keys so toggling checkbox triggers refetch
- Fix invalidation to use prefix keys (['admin','users'], ['admin','packs'],
  ['osm','conditions']) so all cache variants are cleared on mutation
- auth middleware: block soft-deleted users even when JWT is still valid; add
  Number.isFinite guard on uid; add isNull(deletedAt) filter to lastActiveAt update
- Fix OSM member ref from z.number() to z.coerce.bigint() in admin trails route,
  user trails route, and trails service to prevent precision loss for 64-bit OSM IDs

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
Remove the TestIds backward-compat alias from testIds.ts and update
every caller to use the namespaced testIds object directly. Adds
previously-missing keys (cancelBtn, listCard, searchCard, detailName,
etc.) with the exact string values required by Maestro E2E flows.

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
@github-actions github-actions Bot added the mobile label May 1, 2026
The authPlugin now rejects soft-deleted users even with a valid JWT.
Without this fix, if the E2E test account had ever been soft-deleted
(e.g., by a delete-account test), the seed upsert would refresh the
password but leave deletedAt set, causing all authenticated API calls
to return 401 during E2E test runs.

https://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
@andrew-bierman andrew-bierman merged commit b050cf5 into development May 1, 2026
10 of 12 checks passed
@andrew-bierman andrew-bierman deleted the claude/soft-deletes-user-activity-t3jJW branch May 1, 2026 22:58
andrew-bierman added a commit that referenced this pull request May 14, 2026
…ivity-t3jJW

feat: soft deletes, lastActiveAt tracking, and admin trail search
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants