feat: soft deletes, lastActiveAt tracking, and admin trail search#2371
Conversation
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
Deploying with
|
| 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 |
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (20)
WalkthroughThis PR adds soft-delete capabilities with restore functionality for users and packs, refactors list endpoints to return paginated responses with Changes
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~70 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Review rate limit: 0/1 reviews remaining, refill in 40 minutes and 52 seconds.Comment |
Coverage Report for API Unit Tests Coverage (./packages/api)
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Coverage Report for Expo Unit Tests Coverage (./apps/expo)
File CoverageNo changed files found. |
There was a problem hiding this comment.
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_attracking (updated by auth middleware) anddeleted_atcolumns + 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.
| 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, | ||
| }; |
There was a problem hiding this comment.
/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.
| const { mutateAsync: handleRestore } = useMutation({ | ||
| mutationFn: () => restoreUser(user.id), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: queryKeys.admin.users() }); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
| isLoading, | ||
| isError, | ||
| } = useQuery({ | ||
| queryKey: queryKeys.admin.users(q), |
There was a problem hiding this comment.
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.
| queryKey: queryKeys.admin.users(q), | |
| queryKey: [...queryKeys.admin.users(q), { includeDeleted }], |
| isLoading, | ||
| isError, | ||
| } = useQuery({ | ||
| queryKey: queryKeys.admin.packs(q), |
There was a problem hiding this comment.
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.
| queryKey: queryKeys.admin.packs(q), | |
| queryKey: [...queryKeys.admin.packs(q), { includeDeleted }], |
| const { mutateAsync: handleDelete } = useMutation({ | ||
| mutationFn: () => deleteTrailCondition(report.id), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: queryKeys.osm.conditions() }); |
There was a problem hiding this comment.
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).
| queryClient.invalidateQueries({ queryKey: queryKeys.osm.conditions() }); | |
| queryClient.invalidateQueries({ queryKey: ['osm', 'conditions'] }); |
| import { and, count, desc, eq, ilike, isNull, or } from 'drizzle-orm'; | ||
| import { sql } from 'drizzle-orm'; |
There was a problem hiding this comment.
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.
| 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'; |
| 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 |
There was a problem hiding this comment.
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.
| .set({ lastActiveAt: new Date() }) | ||
| .where( | ||
| and( | ||
| eq(users.id, uid), |
There was a problem hiding this comment.
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).
| eq(users.id, uid), | |
| eq(users.id, uid), | |
| isNull(users.deletedAt), |
| // 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 }); |
There was a problem hiding this comment.
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).
| 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}`); | ||
| } |
There was a problem hiding this comment.
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).
- 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
There was a problem hiding this comment.
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 winAdd indexes for the new user activity/deletion filters.
/admin/users-list,/admin/analytics/platform/growth, and/admin/analytics/platform/active-usersnow filter ondeleted_atandlast_active_at, but this table still defines no supporting indexes. On a non-trivialuserstable, those endpoints will degrade into full scans. A partial index onlast_active_atfor non-deleted users, plus an index or partial index coveringdeleted_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 winExclude soft-deleted posts from
/activity.
postsnow hasdeletedAt, but this query still counts every post newer thanstartDate. 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
📒 Files selected for processing (20)
apps/admin/app/dashboard/catalog/page.tsxapps/admin/app/dashboard/packs/page.tsxapps/admin/app/dashboard/page.tsxapps/admin/app/dashboard/trails/page.tsxapps/admin/app/dashboard/users/page.tsxapps/admin/lib/api.tsapps/admin/lib/queryKeys.tspackages/api/drizzle/0038_broad_winter_soldier.sqlpackages/api/drizzle/0039_worried_wrecking_crew.sqlpackages/api/drizzle/meta/0037_snapshot.jsonpackages/api/drizzle/meta/0038_snapshot.jsonpackages/api/drizzle/meta/0039_snapshot.jsonpackages/api/drizzle/meta/_journal.jsonpackages/api/src/db/schema.tspackages/api/src/middleware/auth.tspackages/api/src/routes/admin/analytics/platform.tspackages/api/src/routes/admin/index.tspackages/api/src/routes/admin/trails.tspackages/api/src/utils/__tests__/compute-pack.test.tspackages/api/test/packs.test.ts
| const { data: result, isLoading } = useQuery({ | ||
| queryKey: queryKeys.osm.conditions(activeSearch || undefined), | ||
| queryFn: () => getTrailConditions({ q: activeSearch || undefined }), | ||
| }); |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 }; |
There was a problem hiding this comment.
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.
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
Deploying packrat-guides with
|
| 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 |
Deploying packrat-landing with
|
| 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 |
- 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
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
…ivity-t3jJW feat: soft deletes, lastActiveAt tracking, and admin trail search
Summary
deleted_attimestamp to all user-content tables (packs,pack_items,pack_templates,pack_template_items,trips,trail_condition_reports,posts,post_comments). Existingdeleted: booleancolumns are kept for backward compat; already-deleted rows are backfilled fromupdated_at.DELETE /admin/users/:idnow soft-deletes (setsdeleted_at) instead of hard-deleting. NewDELETE /admin/users/:id/hardendpoint for GDPR-style erasure — requires areasonbody field, logs to console. NewPOST /admin/users/:id/restoreto undo a soft delete.lastActiveAttracking: addslast_active_atto theuserstable. TheauthPluginmiddleware updates it on every authenticated request, rate-limited to once per 5 minutes per user (fire-and-forget, non-blocking).users-list,packs-list,catalog-listnow return{ data, total, limit, offset }instead of flat arrays, exposelastActiveAt/deletedAtfields, and accept?includeDeleted=trueso admins can see soft-deleted records.GET /admin/analytics/platform/active-usersendpoint.GET /admin/trails/searchendpoint (admin JWT, no user session needed) searches OSM routes by name + optional sport. Plus admin-auth versions of/:osmIdand/:osmId/geometry. AlsoGET /admin/trails/conditionsto list all trail condition reports with pagination.Migration (0038)
Test plan
deleted_atis set, user absent from default listdeleted_atclearedlast_active_atupdatedGET /admin/analytics/platform/active-usersreturns DAU/WAU/MAUhttps://claude.ai/code/session_01MfPGarH9nbfeQh7dLaun2X
Summary by CodeRabbit
Release Notes
New Features
Refactor
Chores