feat: add last_login column to user table and update user login tracking

This commit is contained in:
Anish Sarkar 2026-03-08 18:24:29 +05:30
parent 2ac0e4f931
commit a11c95e30f
8 changed files with 70 additions and 3 deletions

View file

@ -0,0 +1,39 @@
"""103_add_last_login_to_user
Revision ID: 103
Revises: 102
Create Date: 2026-03-08
Adds last_login timestamp column to the user table so we can track
when each user last authenticated. The column is nullable existing
rows will have NULL until the user's next login.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "103"
down_revision: str | None = "102"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")]
if "last_login" not in existing_columns:
op.add_column(
"user",
sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("user", "last_login")

View file

@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE":
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user
refresh_tokens = relationship(
"RefreshToken",
@ -1820,6 +1822,8 @@ else:
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
# Refresh tokens for this user
refresh_tokens = relationship(
"RefreshToken",

View file

@ -510,6 +510,7 @@ async def list_members(
"user_email": member_user.email if member_user else None,
"user_display_name": member_user.display_name if member_user else None,
"user_avatar_url": member_user.avatar_url if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
}
response.append(membership_dict)
@ -602,6 +603,7 @@ async def update_member_role(
"created_at": db_membership.created_at,
"role": db_membership.role,
"user_email": member_user.email if member_user else None,
"user_last_login": member_user.last_login if member_user else None,
}
except HTTPException:

View file

@ -77,6 +77,7 @@ class MembershipRead(BaseModel):
user_email: str | None = None
user_display_name: str | None = None
user_avatar_url: str | None = None
user_last_login: datetime | None = None
class Config:
from_attributes = True

View file

@ -1,5 +1,6 @@
import logging
import uuid
from datetime import UTC, datetime
import httpx
from fastapi import Depends, Request, Response
@ -12,6 +13,7 @@ from fastapi_users.authentication import (
)
from fastapi_users.db import SQLAlchemyUserDatabase
from pydantic import BaseModel
from sqlalchemy import update
from app.config import config
from app.db import (
@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
return user
async def on_after_login(
self,
user: User,
request: Request | None = None,
response: Response | None = None,
) -> None:
try:
async with async_session_maker() as session:
await session.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.now(UTC))
)
await session.commit()
except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}")
async def on_after_register(self, user: User, request: Request | None = None):
"""
Called after a user registers. Creates a default search space for the user

View file

@ -546,9 +546,9 @@ function MemberRow({
</div>
</TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
{formatRelativeDate(member.joined_at)}
</TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
</TableCell>
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
{showActions ? (

View file

@ -10,6 +10,7 @@ export const membersAtom = atomWithQuery((get) => {
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
enabled: !!searchSpaceId,
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
refetchInterval: 2 * 60 * 1000, // 2 minutes
queryFn: async () => {
if (!searchSpaceId) {
return [];

View file

@ -13,6 +13,7 @@ export const membership = z.object({
user_email: z.string().nullable().optional(),
user_display_name: z.string().nullable().optional(),
user_avatar_url: z.string().nullable().optional(),
user_last_login: z.string().nullable().optional(),
user_is_active: z.boolean().nullable().optional(),
});