Fix PostgreSQL timestamp conversion for POST/PATCH endpoints

Convert milliseconds timestamps from Discord bot to datetime objects
for PostgreSQL DateTimeField columns in notifications, packs, paperdex,
and rewards routers. Also fix rewards GET created_after filter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 22:37:31 -06:00
parent cb89a61196
commit 127c4fca65
5 changed files with 154 additions and 14 deletions

View File

@ -1,14 +1,14 @@
{
"meta": {
"version": "1.1.0",
"version": "1.2.0",
"created": "2026-01-25",
"lastUpdated": "2026-01-25",
"lastUpdated": "2026-01-30",
"planType": "migration",
"description": "SQLite to PostgreSQL migration for Paper Dynasty database API",
"branch": "postgres-migration",
"totalEstimatedHours": 22,
"totalTasks": 16,
"completedTasks": 13
"totalEstimatedHours": 26,
"totalTasks": 28,
"completedTasks": 18
},
"context": {
"sourceDatabase": {
@ -453,6 +453,143 @@
"estimatedHours": 3
}
},
"timestampFixTasks": {
"description": "Post-migration timestamp type mismatch fixes - Discord bot sends milliseconds integers but PostgreSQL uses DateTimeField",
"pattern": {
"postHandler": "datetime.fromtimestamp(field / 1000)",
"getFilter": "param_dt = datetime.fromtimestamp(param / 1000)",
"nullableField": "Use None instead of 0",
"nullCheck": "Model.field.is_null() instead of == 0"
},
"tasks": [
{
"id": "TS-001",
"name": "Fix rewards.py GET created_after filter",
"category": "critical",
"completed": true,
"file": "app/routers_v2/rewards.py",
"lines": [46, 47],
"notes": "Fixed 2026-01-30. Was causing /comeonmanineedthis to fail."
},
{
"id": "TS-002",
"name": "Fix notifications.py POST created field",
"category": "critical",
"completed": true,
"file": "app/routers_v2/notifications.py",
"lines": [23, 115],
"botUsage": ["helpers/main.py:524", "helpers/main.py:779"],
"notes": "Breaks rare card pull notifications"
},
{
"id": "TS-003",
"name": "Fix notifications.py GET created_after filter",
"category": "critical",
"completed": false,
"file": "app/routers_v2/notifications.py",
"lines": [34, 44],
"notes": "Used for notification polling"
},
{
"id": "TS-004",
"name": "Fix packs.py POST open_time field",
"category": "critical",
"completed": true,
"file": "app/routers_v2/packs.py",
"lines": [29, 158, 184],
"botUsage": ["cogs/economy.py:1287", "cogs/economy_new/team_setup.py:242", "cogs/economy_new/admin_tools.py:76,115,147"],
"notes": "Breaks starter pack creation and admin pack distribution"
},
{
"id": "TS-005",
"name": "Fix packs.py PATCH open_time field",
"category": "critical",
"completed": true,
"file": "app/routers_v2/packs.py",
"lines": [199, 230, 234],
"notes": "Used when updating pack open times"
},
{
"id": "TS-006",
"name": "Fix paperdex.py POST created field",
"category": "critical",
"completed": true,
"file": "app/routers_v2/paperdex.py",
"lines": [26, 121],
"botUsage": ["api_calls.py:post_to_dex()"],
"notes": "Breaks collection tracking"
},
{
"id": "TS-007",
"name": "Fix paperdex.py GET created_after/before filters",
"category": "high",
"completed": false,
"file": "app/routers_v2/paperdex.py",
"lines": [31, 32, 48, 50],
"notes": "Used for filtering paperdex by date range"
},
{
"id": "TS-008",
"name": "Fix gauntletruns.py GET timestamp filters (4 fields)",
"category": "high",
"completed": false,
"file": "app/routers_v2/gauntletruns.py",
"lines": [36, 37, 58, 60, 62, 64],
"notes": "created_after, created_before, ended_after, ended_before"
},
{
"id": "TS-009",
"name": "Fix gauntletruns.py GET is_active filter (NULL vs 0)",
"category": "high",
"completed": false,
"file": "app/routers_v2/gauntletruns.py",
"lines": [67, 69],
"notes": "Should use is_null() not == 0"
},
{
"id": "TS-010",
"name": "Fix gauntletruns.py PATCH timestamp fields",
"category": "high",
"completed": false,
"file": "app/routers_v2/gauntletruns.py",
"lines": [122, 124, 129, 131],
"notes": "Assigns int to DateTimeField, uses 0 instead of None"
},
{
"id": "TS-011",
"name": "Fix gauntletruns.py POST timestamp fields",
"category": "high",
"completed": false,
"file": "app/routers_v2/gauntletruns.py",
"lines": [28, 29, 152],
"notes": "Pydantic model uses int with default 0"
},
{
"id": "TS-012",
"name": "Fix batstats.py GET created filter",
"category": "medium",
"completed": false,
"file": "app/routers_v2/batstats.py",
"lines": [74, 98],
"notes": "Not actively used - commented out in gameplay_legacy.py"
},
{
"id": "TS-013",
"name": "Fix pitstats.py GET created filter",
"category": "medium",
"completed": false,
"file": "app/routers_v2/pitstats.py",
"lines": [60, 85],
"notes": "Not actively used - commented out in gameplay_legacy.py"
}
],
"implementationOrder": [
{"phase": 1, "name": "Critical POST Endpoints", "tasks": ["TS-002", "TS-004", "TS-005", "TS-006"]},
{"phase": 2, "name": "Critical GET Filters", "tasks": ["TS-003", "TS-007"]},
{"phase": 3, "name": "Gauntlet System", "tasks": ["TS-008", "TS-009", "TS-010", "TS-011"]},
{"phase": 4, "name": "Stats Endpoints", "tasks": ["TS-012", "TS-013"]}
]
},
"rollbackPlan": {
"triggers": [
"Data corruption detected",

View File

@ -1,3 +1,4 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from typing import Optional
import logging
@ -112,7 +113,7 @@ async def post_notif(notif: NotifModel, token: str = Depends(oauth2_scheme)):
logging.info(f'new notif: {notif}')
this_notif = Notification(
created=notif.created,
created=datetime.fromtimestamp(notif.created / 1000),
title=notif.title,
desc=notif.desc,
field_name=notif.field_name,
@ -164,7 +165,7 @@ async def patch_notif(
if ack is not None:
this_notif.ack = ack
if created is not None:
this_notif.created = created
this_notif.created = datetime.fromtimestamp(created / 1000)
if this_notif.save() == 1:
return_val = model_to_dict(this_notif)

View File

@ -26,7 +26,7 @@ class PackPydantic(pydantic.BaseModel):
pack_type_id: int
pack_team_id: Optional[int] = None
pack_cardset_id: Optional[int] = None
open_time: Optional[str] = None
open_time: Optional[int] = None
class PackModel(pydantic.BaseModel):
@ -155,7 +155,7 @@ async def post_pack(packs: PackModel, token: str = Depends(oauth2_scheme)):
pack_type_id=x.pack_type_id,
pack_team_id=x.pack_team_id,
pack_cardset_id=x.pack_cardset_id,
open_time=x.open_time if x.open_time != "" else None
open_time=datetime.fromtimestamp(x.open_time / 1000) if x.open_time else None
)
new_packs.append(this_player)
@ -181,7 +181,7 @@ async def post_one_pack(pack: PackPydantic, token: str = Depends(oauth2_scheme))
pack_type_id=pack.pack_type_id,
pack_team_id=pack.pack_team_id,
pack_cardset_id=pack.pack_cardset_id,
open_time=pack.open_time
open_time=datetime.fromtimestamp(pack.open_time / 1000) if pack.open_time else None
)
saved = this_pack.save()
@ -231,7 +231,7 @@ async def patch_pack(
if open_time < 0:
this_pack.open_time = None
else:
this_pack.open_time = open_time
this_pack.open_time = datetime.fromtimestamp(open_time / 1000)
if this_pack.save() == 1:
return_val = model_to_dict(this_pack)

View File

@ -118,7 +118,7 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch
this_dex = Paperdex(
team_id=paperdex.team_id,
player_id=paperdex.player_id,
created=paperdex.created
created=datetime.fromtimestamp(paperdex.created / 1000)
)
saved = this_dex.save()
@ -155,7 +155,7 @@ async def patch_paperdex(
if player_id is not None:
this_dex.player_id = player_id
if created is not None:
this_dex.created = created
this_dex.created = datetime.fromtimestamp(created / 1000)
if this_dex.save() == 1:
return_val = model_to_dict(this_dex)

View File

@ -44,7 +44,9 @@ async def get_rewards(
if team_id is not None:
all_rewards = all_rewards.where(Reward.team_id == team_id)
if created_after is not None:
all_rewards = all_rewards.where(Reward.created >= created_after)
# Convert milliseconds timestamp to datetime for PostgreSQL comparison
created_after_dt = datetime.fromtimestamp(created_after / 1000)
all_rewards = all_rewards.where(Reward.created >= created_after_dt)
if in_name is not None:
all_rewards = all_rewards.where(fn.Lower(Reward.name).contains(in_name.lower()))
if season is not None: