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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,34 @@ PR review files go in `dev_docs/pull_requests/{year}/{pr_number}-{slug}/CLAUDE_R

**Use inline icon buttons for table/list row actions**, not dropdown menus. Pattern: `flex gap-1` with `btn btn-xs btn-ghost` icon-only buttons and `title` tooltips. Dropdown menus are OK for selectors (status, format, version pickers) but not for CRUD actions.

### Structs Over Plain Maps

**Prefer structs over plain maps when a struct type exists.** Use the struct or its `.new/1` constructor in application code rather than bare maps.

**Key struct types:**
- `PhoenixKit.Dashboard.Tab` - Tab configuration (`Tab.new/1`)
- `PhoenixKit.Dashboard.Badge` - Badge configuration (`Badge.new/1`)
- `PhoenixKit.Dashboard.ContextSelector` - Context selector configuration

**Exception:** In `config/config.exs`, plain maps are idiomatic and correct — `.new/1` constructors accept maps and convert them to structs at runtime.

```elixir
# ✅ In application code, use structs
Tab.new(%{id: :orders, label: "Orders", path: "/dashboard/orders"})

# ✅ In config/config.exs, plain maps are fine (converted at runtime)
config :phoenix_kit, :user_dashboard_tabs, [
%{id: :orders, label: "Orders", path: "/dashboard/orders"}
]

# ❌ In application code, avoid plain maps when struct exists
%{id: :orders, label: "Orders", path: "/dashboard/orders"}
```

### DateTime: Always Use `DateTime.utc_now()`

**Always use `:utc_datetime` and `DateTime.utc_now()` in new code.** Never use `NaiveDateTime` in new schemas or application code. See the [DateTime Convention](#datetime-convention) section for the full reference table and rationale.

### URL Prefix and Navigation (IMPORTANT)

**NEVER hardcode PhoenixKit paths.** PhoenixKit uses a configurable URL prefix (default: `/phoenix_kit`). Always use the provided helpers to ensure paths work correctly regardless of prefix configuration.
Expand Down Expand Up @@ -584,6 +612,21 @@ belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7
belongs_to :user, User, foreign_key: :user_uuid, type: UUIDv7
```

### DateTime Convention

**Always use `:utc_datetime` and `DateTime.utc_now()` in new code.** Never use `NaiveDateTime` in new schemas or application code.

| Context | Use | Never Use |
|---------|-----|-----------|
| Schema timestamps | `timestamps(type: :utc_datetime)` | `timestamps()` or `timestamps(type: :naive_datetime)` |
| Individual datetime fields | `field :name, :utc_datetime` | `field :name, :naive_datetime` |
| Application code | `DateTime.utc_now()` | `NaiveDateTime.utc_now()` |
| Bulk insert timestamps | `DateTime.utc_now()` | `NaiveDateTime.utc_now() \|> NaiveDateTime.truncate(:second)` |

**Existing `:utc_datetime_usec` schemas are acceptable** — no need to downgrade them. `DateTime.utc_now()` works with both `:utc_datetime` and `:utc_datetime_usec` fields (Ecto handles precision automatically).

**Why:** A production bug was caused by copying `NaiveDateTime.utc_now()` into a `:utc_datetime_usec` schema. Using `DateTime.utc_now()` everywhere prevents this class of bugs. See `dev_docs/2026-02-15-datetime-inconsistency-report.md` for full details.

### Key Design Principles

- **No Circular Dependencies** - Optional Phoenix deps prevent import cycles
Expand Down
128 changes: 48 additions & 80 deletions dev_docs/2026-02-15-datetime-inconsistency-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Date:** 2026-02-15
**Severity:** Medium (already caused a production bug)
**Recommendation:** Standardize on `timestamptz` + `:utc_datetime_usec` everywhere
**Recommendation:** Standardize on `:utc_datetime` + `DateTime.utc_now()` everywhere (existing `:utc_datetime_usec` schemas left as-is)

---

Expand Down Expand Up @@ -110,97 +110,64 @@ Code that receives values from different schemas needs to handle all three, or r

---

## Recommendation: Standardize on `timestamptz` + `:utc_datetime_usec`
## Recommendation: Standardize on `:utc_datetime` + `DateTime.utc_now()`

### Target State

| Layer | Standard | Notes |
|-------|----------|-------|
| **PostgreSQL** | `timestamptz` | Timezone-aware, auto-normalizes to UTC |
| **Ecto Schema** | `:utc_datetime_usec` | Returns `DateTime` with microsecond precision |
| **Application Code** | `DateTime.utc_now()` | Consistent everywhere |
| **Default timestamps** | `timestamps(type: :utc_datetime_usec)` | Override Ecto's default |
| **Ecto Schema** | `:utc_datetime` | Returns `DateTime` with second precision |
| **Application Code** | `DateTime.utc_now()` | Consistent everywhere, already returns second precision |
| **Default timestamps** | `timestamps(type: :utc_datetime)` | Override Ecto's default |
| **Existing `:utc_datetime_usec`** | Leave as-is | Already works correctly, no need to downgrade |

### Why `timestamptz` over `timestamp`
### Why `:utc_datetime` (not `:utc_datetime_usec`)

PostgreSQL's `timestamptz` stores the value in UTC internally (same storage cost) but provides timezone conversion on input/output. If a client connects with a non-UTC timezone, `timestamptz` handles the conversion correctly. With plain `timestamp`, that same value would be silently misinterpreted.
- Microsecond precision is not needed for this application
- Second precision matches the existing `timestamp(0)` database columns — no DB migration required
- `DateTime.utc_now()` returns second precision by default, so no `truncate/2` calls needed
- Simpler migration path: only schema + application code changes, no database column alterations

### Why `:utc_datetime_usec` over `:utc_datetime`
### Why not `:naive_datetime`

- Modern Ecto/Phoenix generators default to `_usec` variants
- No precision loss — you get the full resolution PostgreSQL offers
- One fewer type to think about
- The "usec" suffix is misleading — it doesn't cost extra storage, it just preserves what PostgreSQL already stores
- `NaiveDateTime` has no timezone information, making it easy to misuse
- `DateTime` with UTC timezone is explicit about the timezone context
- Copying `DateTime.utc_now()` between any modules (`:utc_datetime` or `:utc_datetime_usec`) works correctly
- Ecto automatically handles `DateTime` → `:utc_datetime_usec` promotion (adds zero microseconds)

### Database Migration (Deferred)

Converting `timestamp(0)` → `timestamptz` columns is deferred to a separate V58 migration. The schema-level change to `:utc_datetime` is safe with existing `timestamp(0)` columns because Ecto handles the conversion transparently.

---

## Migration Plan

### Phase 1: Prevent New Inconsistencies (Immediate)

1. **Add project-wide convention to CLAUDE.md and CONTRIBUTING.md:**
- Always use `timestamps(type: :utc_datetime_usec)` in schemas
- Always use `DateTime.utc_now()` in application code
- Always use `timestamptz` in raw SQL migrations

2. **Add a Credo check or compile-time warning** for `NaiveDateTime.utc_now()` in non-migration code.

### Phase 2: Align Schemas with Database (Low Risk)

Update all Ecto schemas to use `:utc_datetime_usec` for existing columns. Since the database columns with precision=6 already store microseconds, the schema change is backwards-compatible — Ecto will just return `DateTime` instead of `NaiveDateTime`.

**Schemas to update:**
- `User` — `confirmed_at`, `timestamps()`
- `Role` — `timestamps()`
- `AdminNote` — `timestamps()`
- `RoleAssignment` — `assigned_at`
- All Connections schemas — `inserted_at`, `requested_at`, `responded_at`
- All Shop schemas — `timestamps()`
- `Subscription` — all 8 datetime fields
- `WebhookEvent` — `processed_at`
- Storage schemas — `last_verified_at`, `timestamps()`

**Application code to update (use `DateTime.utc_now()` instead of `NaiveDateTime.utc_now()`):**
- `lib/phoenix_kit/users/auth/user.ex:322`
- `lib/phoenix_kit/users/sessions.ex:234`
- `lib/phoenix_kit/users/permissions.ex:504`
- `lib/phoenix_kit/users/magic_link_registration.ex:166`
- `lib/phoenix_kit/users/roles.ex:783`
- `lib/phoenix_kit/users/role_assignment.ex:113`
- `lib/modules/storage/storage.ex:238`
- `lib/modules/connections/follow.ex:99`
- `lib/modules/connections/block.ex:110`
- `lib/modules/connections/block_history.ex:51`
- `lib/modules/connections/connection.ex:153,165`
- `lib/modules/connections/connection_history.ex:83`
- `lib/modules/connections/follow_history.ex:50`
- `lib/modules/comments/comments.ex:302`

### Phase 3: Database Migration (Requires Downtime Planning)

A versioned migration (V57 or later) to alter all 64 precision-0 columns:

```sql
-- Convert timestamp(0) to timestamptz with microsecond precision
-- This is a metadata-only change in PostgreSQL for columns that already store UTC
ALTER TABLE phoenix_kit_users
ALTER COLUMN confirmed_at TYPE timestamptz USING confirmed_at AT TIME ZONE 'UTC',
ALTER COLUMN inserted_at TYPE timestamptz USING inserted_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';

-- Repeat for all 34 affected tables...
```
### Phase 1: Standardize Schemas and Application Code (COMPLETED 2026-02-17)

All schemas and application code have been updated:

1. **Schema timestamps:** All `timestamps()` and `timestamps(type: :naive_datetime)` → `timestamps(type: :utc_datetime)`
2. **Individual fields:** All `field :name, :naive_datetime` → `field :name, :utc_datetime`
3. **Application code:** All `NaiveDateTime.utc_now()` → `DateTime.utc_now()` in non-display code
4. **Convention added to CLAUDE.md** to prevent future regressions

**Important notes:**
- `ALTER COLUMN ... TYPE` on large tables may require a lock. For tables with millions of rows, consider doing this during a maintenance window or using `pg_repack`.
- The `AT TIME ZONE 'UTC'` clause tells PostgreSQL that existing values are UTC (they are, since Ecto always writes UTC).
- Also convert the 135 precision-6 `timestamp` columns to `timestamptz` for timezone safety.
**Files updated:** ~38 schema files, 9 field type files, 14 application code files

### Phase 4: Verify and Clean Up
**What was NOT changed:**
- Schemas already on `:utc_datetime_usec` (left as-is, they work correctly)
- Schemas already on `:utc_datetime` (already correct)
- Display/formatter code in `time_display.ex` and `file_display.ex` (handles both DateTime and NaiveDateTime)

- Run full integration test suite against a parent application
- Remove any `NaiveDateTime` imports/aliases that are no longer needed
- Update any date formatting utilities that special-case `NaiveDateTime`
### Phase 2: Database Migration (Deferred)

Converting `timestamp(0)` → `timestamptz` columns deferred to a separate V58 migration. The schema-level changes are safe with existing columns.

### Phase 3: Verify and Clean Up

- Compile with `--warnings-as-errors` — no type warnings
- Run `mix test`, `mix format`, `mix credo --strict`
- Verify no remaining `NaiveDateTime.utc_now()` in application code (only in display utilities)

---

Expand Down Expand Up @@ -368,10 +335,11 @@ lib/modules/comments/comments.ex:302 ✅

### Recommendation Priority Update

Based on verification findings:
**Status: Phase 1 COMPLETED (2026-02-17)**

All schema and application code standardized on `:utc_datetime` + `DateTime.utc_now()`. Convention added to CLAUDE.md.

1. **Immediate (High Risk):** Add compile-time check or Credo rule for `NaiveDateTime.utc_now()` in non-migration code - this prevents new bugs being introduced
2. **Short-term:** Fix the 17 files using `NaiveDateTime.utc_now()` in application code (especially `permissions.ex`, `comments.ex`, `storage.ex`)
3. **Medium-term:** Align Ecto schemas to use `:utc_datetime_usec` (backwards-compatible change)
4. **Long-term:** Database migration to `timestamptz` (requires downtime planning)
**Remaining:**
1. **Database migration (V58):** Convert `timestamp(0)` → `timestamptz` columns (deferred, requires downtime planning)
2. **Optional:** Add Credo check or compile-time warning for `NaiveDateTime.utc_now()` to prevent regressions

Loading
Loading