feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -7,6 +7,7 @@ from . import (
|
||||
promo_code_dal,
|
||||
security_dal,
|
||||
subscription_dal,
|
||||
support_dal,
|
||||
user_billing_dal,
|
||||
user_dal,
|
||||
)
|
||||
@@ -22,4 +23,5 @@ __all__ = (
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
"app_settings_dal",
|
||||
"support_dal",
|
||||
)
|
||||
|
||||
@@ -37,6 +37,17 @@ async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
|
||||
return {row.key: _decode(row.value) for row in rows}
|
||||
|
||||
|
||||
async def get_override_value(session: AsyncSession, key: str) -> Tuple[bool, Any]:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(AppSettingOverride).where(AppSettingOverride.key == key).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False, None
|
||||
return True, _decode(row.value)
|
||||
|
||||
|
||||
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
|
||||
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, case, desc, func, or_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..models import SupportTicket, SupportTicketMessage, User
|
||||
|
||||
ACTIVE_STATUSES = {"open", "awaiting_user", "awaiting_admin"}
|
||||
CLOSED_STATUSES = {"resolved", "closed"}
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _status_condition(status: Optional[str]):
|
||||
normalized = (status or "").strip().lower()
|
||||
if not normalized or normalized in {"all", "any"}:
|
||||
return None
|
||||
if normalized == "active":
|
||||
return SupportTicket.status.in_(ACTIVE_STATUSES)
|
||||
if normalized == "closed":
|
||||
return SupportTicket.status.in_(CLOSED_STATUSES)
|
||||
return SupportTicket.status == normalized
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
subject: str,
|
||||
category: str,
|
||||
priority: str,
|
||||
first_message_body: str,
|
||||
) -> SupportTicket:
|
||||
now = datetime.now(timezone.utc)
|
||||
ticket = SupportTicket(
|
||||
user_id=user_id,
|
||||
subject=subject,
|
||||
category=category,
|
||||
priority=priority,
|
||||
status="awaiting_admin",
|
||||
last_message_at=now,
|
||||
last_message_role="user",
|
||||
unread_admin_count=1,
|
||||
unread_user_count=0,
|
||||
admin_last_notified_at=now,
|
||||
admin_last_emailed_at=now,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
message = SupportTicketMessage(
|
||||
ticket_id=ticket.ticket_id,
|
||||
author_role="user",
|
||||
author_user_id=user_id,
|
||||
body=first_message_body,
|
||||
is_internal_note=False,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
async def add_message(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
author_role: str,
|
||||
author_user_id: Optional[int],
|
||||
body: str,
|
||||
is_internal_note: bool = False,
|
||||
) -> Optional[SupportTicketMessage]:
|
||||
stmt = select(SupportTicket).where(SupportTicket.ticket_id == ticket_id).with_for_update()
|
||||
result = await session.execute(stmt)
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
message = SupportTicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
author_role=author_role,
|
||||
author_user_id=author_user_id,
|
||||
body=body,
|
||||
is_internal_note=bool(is_internal_note),
|
||||
created_at=now,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
ticket.last_message_at = now
|
||||
ticket.last_message_role = author_role
|
||||
ticket.updated_at = now
|
||||
if author_role == "user":
|
||||
ticket.unread_admin_count = int(ticket.unread_admin_count or 0) + 1
|
||||
if ticket.status not in CLOSED_STATUSES:
|
||||
ticket.status = "awaiting_admin"
|
||||
elif author_role == "admin" and not is_internal_note:
|
||||
ticket.unread_user_count = int(ticket.unread_user_count or 0) + 1
|
||||
if ticket.status not in CLOSED_STATUSES:
|
||||
ticket.status = "awaiting_user"
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
async def record_admin_notification(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
notified_at: Optional[datetime] = None,
|
||||
emailed_at: Optional[datetime] = None,
|
||||
) -> None:
|
||||
values = {}
|
||||
if notified_at is not None:
|
||||
values["admin_last_notified_at"] = notified_at
|
||||
if emailed_at is not None:
|
||||
values["admin_last_emailed_at"] = emailed_at
|
||||
if not values:
|
||||
return
|
||||
await session.execute(
|
||||
update(SupportTicket).where(SupportTicket.ticket_id == ticket_id).values(**values)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_ticket(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
include_internal: bool = False,
|
||||
) -> tuple[Optional[SupportTicket], list[SupportTicketMessage]]:
|
||||
stmt = (
|
||||
select(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.options(selectinload(SupportTicket.user))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
return None, []
|
||||
|
||||
msg_stmt = select(SupportTicketMessage).where(SupportTicketMessage.ticket_id == ticket_id)
|
||||
if not include_internal:
|
||||
msg_stmt = msg_stmt.where(SupportTicketMessage.is_internal_note.is_(False))
|
||||
msg_stmt = msg_stmt.order_by(
|
||||
SupportTicketMessage.created_at.asc(),
|
||||
SupportTicketMessage.message_id.asc(),
|
||||
)
|
||||
msg_result = await session.execute(msg_stmt)
|
||||
return ticket, list(msg_result.scalars().all())
|
||||
|
||||
|
||||
async def list_user_tickets(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> list[SupportTicket]:
|
||||
stmt = select(SupportTicket).where(SupportTicket.user_id == user_id)
|
||||
status_cond = _status_condition(status_filter)
|
||||
if status_cond is not None:
|
||||
stmt = stmt.where(status_cond)
|
||||
stmt = (
|
||||
stmt.order_by(desc(SupportTicket.last_message_at), desc(SupportTicket.ticket_id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_admin_tickets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
assigned_admin_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
sort: str = "updated_desc",
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[SupportTicket]:
|
||||
stmt = select(SupportTicket).join(User, User.user_id == SupportTicket.user_id)
|
||||
status_cond = _status_condition(status)
|
||||
if status_cond is not None:
|
||||
stmt = stmt.where(status_cond)
|
||||
if priority:
|
||||
stmt = stmt.where(SupportTicket.priority == priority)
|
||||
if category:
|
||||
stmt = stmt.where(SupportTicket.category == category)
|
||||
if assigned_admin_id is not None:
|
||||
stmt = stmt.where(SupportTicket.assigned_admin_id == assigned_admin_id)
|
||||
if search:
|
||||
pattern = f"%{search.strip().lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.lower(SupportTicket.subject).like(pattern),
|
||||
func.lower(User.username).like(pattern),
|
||||
func.lower(User.first_name).like(pattern),
|
||||
func.lower(User.email).like(pattern),
|
||||
)
|
||||
)
|
||||
priority_rank = case(
|
||||
(SupportTicket.priority == "urgent", 4),
|
||||
(SupportTicket.priority == "high", 3),
|
||||
(SupportTicket.priority == "normal", 2),
|
||||
(SupportTicket.priority == "low", 1),
|
||||
else_=0,
|
||||
)
|
||||
sort_key = (sort or "updated_desc").strip().lower()
|
||||
sort_map = {
|
||||
"updated_desc": (SupportTicket.last_message_at.desc().nullslast(),),
|
||||
"updated_asc": (SupportTicket.last_message_at.asc().nullslast(),),
|
||||
"created_desc": (SupportTicket.created_at.desc().nullslast(),),
|
||||
"created_asc": (SupportTicket.created_at.asc().nullslast(),),
|
||||
"importance_desc": (
|
||||
priority_rank.desc(),
|
||||
SupportTicket.last_message_at.desc().nullslast(),
|
||||
),
|
||||
"importance_asc": (
|
||||
priority_rank.asc(),
|
||||
SupportTicket.last_message_at.desc().nullslast(),
|
||||
),
|
||||
}
|
||||
order_by = sort_map.get(sort_key, sort_map["updated_desc"])
|
||||
stmt = stmt.options(selectinload(SupportTicket.user)).order_by(
|
||||
*order_by,
|
||||
desc(SupportTicket.ticket_id),
|
||||
)
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().unique().all())
|
||||
|
||||
|
||||
async def user_ticket_counts(session: AsyncSession, user_id: int) -> dict:
|
||||
stmt = (
|
||||
select(SupportTicket.status, func.count())
|
||||
.where(SupportTicket.user_id == user_id)
|
||||
.group_by(SupportTicket.status)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
by_status = {str(status): int(count or 0) for status, count in result.all()}
|
||||
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
|
||||
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
|
||||
return {
|
||||
**by_status,
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"total": active + closed,
|
||||
}
|
||||
|
||||
|
||||
async def mark_read(session: AsyncSession, ticket_id: int, role: str) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if role == "user":
|
||||
await session.execute(
|
||||
update(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.values(unread_user_count=0, updated_at=now)
|
||||
)
|
||||
await session.execute(
|
||||
update(SupportTicketMessage)
|
||||
.where(
|
||||
and_(
|
||||
SupportTicketMessage.ticket_id == ticket_id,
|
||||
SupportTicketMessage.author_role == "admin",
|
||||
SupportTicketMessage.is_internal_note.is_(False),
|
||||
SupportTicketMessage.read_by_user_at.is_(None),
|
||||
)
|
||||
)
|
||||
.values(read_by_user_at=now)
|
||||
)
|
||||
elif role == "admin":
|
||||
await session.execute(
|
||||
update(SupportTicket)
|
||||
.where(SupportTicket.ticket_id == ticket_id)
|
||||
.values(unread_admin_count=0, updated_at=now)
|
||||
)
|
||||
await session.execute(
|
||||
update(SupportTicketMessage)
|
||||
.where(
|
||||
and_(
|
||||
SupportTicketMessage.ticket_id == ticket_id,
|
||||
SupportTicketMessage.author_role == "user",
|
||||
SupportTicketMessage.read_by_admin_at.is_(None),
|
||||
)
|
||||
)
|
||||
.values(read_by_admin_at=now)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_ticket(
|
||||
session: AsyncSession,
|
||||
ticket_id: int,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
assigned_admin_id: object = _UNSET,
|
||||
closed_by_admin_id: Optional[int] = None,
|
||||
) -> Optional[SupportTicket]:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if not ticket:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if status is not None:
|
||||
ticket.status = status
|
||||
if status == "closed":
|
||||
ticket.closed_at = now
|
||||
ticket.closed_by_admin_id = closed_by_admin_id
|
||||
elif status != "closed":
|
||||
ticket.closed_at = None
|
||||
ticket.closed_by_admin_id = None
|
||||
if priority is not None:
|
||||
ticket.priority = priority
|
||||
if category is not None:
|
||||
ticket.category = category
|
||||
if assigned_admin_id is not _UNSET:
|
||||
ticket.assigned_admin_id = assigned_admin_id
|
||||
ticket.updated_at = now
|
||||
await session.flush()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
async def count_user_unread(session: AsyncSession, user_id: int) -> int:
|
||||
stmt = select(func.coalesce(func.sum(SupportTicket.unread_user_count), 0)).where(
|
||||
SupportTicket.user_id == user_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def admin_stats(session: AsyncSession) -> dict:
|
||||
status_result = await session.execute(
|
||||
select(SupportTicket.status, func.count()).group_by(SupportTicket.status)
|
||||
)
|
||||
by_status = {str(status): int(count or 0) for status, count in status_result.all()}
|
||||
unread_result = await session.execute(
|
||||
select(func.coalesce(func.sum(SupportTicket.unread_admin_count), 0))
|
||||
)
|
||||
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
|
||||
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
|
||||
return {
|
||||
**by_status,
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"total": active + closed,
|
||||
"open": by_status.get("open", 0),
|
||||
"awaiting_admin": by_status.get("awaiting_admin", 0),
|
||||
"awaiting_user": by_status.get("awaiting_user", 0),
|
||||
"total_unread_admin": int(unread_result.scalar_one() or 0),
|
||||
}
|
||||
|
||||
|
||||
async def count_recent_tickets_for_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
window_seconds: int,
|
||||
) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=max(1, int(window_seconds)))
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(SupportTicket)
|
||||
.where(SupportTicket.user_id == user_id, SupportTicket.created_at >= cutoff)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
@@ -754,6 +754,126 @@ def _migration_0023_add_email_password_auth_fields(connection: Connection) -> No
|
||||
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
|
||||
|
||||
|
||||
def _migration_0024_add_support_tickets(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
ticket_id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
subject VARCHAR(160) NOT NULL,
|
||||
category VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'normal',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'open',
|
||||
assigned_admin_id BIGINT NULL,
|
||||
last_message_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_message_role VARCHAR(16) NULL,
|
||||
unread_user_count INTEGER NOT NULL DEFAULT 0,
|
||||
unread_admin_count INTEGER NOT NULL DEFAULT 0,
|
||||
admin_last_notified_at TIMESTAMPTZ NULL,
|
||||
admin_last_emailed_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
closed_at TIMESTAMPTZ NULL,
|
||||
closed_by_admin_id BIGINT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS support_ticket_messages (
|
||||
message_id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE,
|
||||
author_role VARCHAR(16) NOT NULL,
|
||||
author_user_id BIGINT NULL REFERENCES users(user_id),
|
||||
body TEXT NOT NULL,
|
||||
is_internal_note BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
read_by_user_at TIMESTAMPTZ NULL,
|
||||
read_by_admin_at TIMESTAMPTZ NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
inspector = inspect(connection)
|
||||
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
|
||||
ticket_column_sql = {
|
||||
"user_id": "BIGINT NOT NULL REFERENCES users(user_id)",
|
||||
"subject": "VARCHAR(160) NOT NULL DEFAULT ''",
|
||||
"category": "VARCHAR(32) NOT NULL DEFAULT 'other'",
|
||||
"priority": "VARCHAR(16) NOT NULL DEFAULT 'normal'",
|
||||
"status": "VARCHAR(24) NOT NULL DEFAULT 'open'",
|
||||
"assigned_admin_id": "BIGINT NULL",
|
||||
"last_message_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"last_message_role": "VARCHAR(16) NULL",
|
||||
"unread_user_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"unread_admin_count": "INTEGER NOT NULL DEFAULT 0",
|
||||
"admin_last_notified_at": "TIMESTAMPTZ NULL",
|
||||
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
|
||||
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"updated_at": "TIMESTAMPTZ NULL",
|
||||
"closed_at": "TIMESTAMPTZ NULL",
|
||||
"closed_by_admin_id": "BIGINT NULL",
|
||||
}
|
||||
for column, definition in ticket_column_sql.items():
|
||||
if column not in ticket_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
message_columns: Set[str] = {
|
||||
col["name"] for col in inspector.get_columns("support_ticket_messages")
|
||||
}
|
||||
message_column_sql = {
|
||||
"ticket_id": "INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE",
|
||||
"author_role": "VARCHAR(16) NOT NULL DEFAULT 'user'",
|
||||
"author_user_id": "BIGINT NULL REFERENCES users(user_id)",
|
||||
"body": "TEXT NOT NULL DEFAULT ''",
|
||||
"is_internal_note": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
|
||||
"read_by_user_at": "TIMESTAMPTZ NULL",
|
||||
"read_by_admin_at": "TIMESTAMPTZ NULL",
|
||||
}
|
||||
for column, definition in message_column_sql.items():
|
||||
if column not in message_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_ticket_messages ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
index_statements = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_category ON support_tickets (category)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_priority ON support_tickets (priority)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_assigned_admin_id ON support_tickets (assigned_admin_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_last_message_at ON support_tickets (last_message_at)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status_last_msg ON support_tickets (status, last_message_at)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_ticket_id ON support_ticket_messages (ticket_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_author_user_id ON support_ticket_messages (author_user_id)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_is_internal_note ON support_ticket_messages (is_internal_note)", # noqa: E501
|
||||
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_created_at ON support_ticket_messages (created_at)", # noqa: E501
|
||||
]
|
||||
for stmt in index_statements:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
def _migration_0025_add_support_notification_timestamps(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
|
||||
column_sql = {
|
||||
"admin_last_notified_at": "TIMESTAMPTZ NULL",
|
||||
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
|
||||
}
|
||||
for column, definition in column_sql.items():
|
||||
if column not in ticket_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -881,6 +1001,16 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Store hashed passwords for optional email password login",
|
||||
upgrade=_migration_0023_add_email_password_auth_fields,
|
||||
),
|
||||
Migration(
|
||||
id="0024_add_support_tickets",
|
||||
description="Add support ticket inbox and messages",
|
||||
upgrade=_migration_0024_add_support_tickets,
|
||||
),
|
||||
Migration(
|
||||
id="0025_add_support_notification_timestamps",
|
||||
description="Track support ticket admin notification cooldown timestamps",
|
||||
upgrade=_migration_0025_add_support_notification_timestamps,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -374,6 +374,62 @@ class MessageLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
ticket_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
subject = Column(String(160), nullable=False)
|
||||
category = Column(String(32), nullable=False, default="other", index=True)
|
||||
priority = Column(String(16), nullable=False, default="normal", index=True)
|
||||
status = Column(String(24), nullable=False, default="open", index=True)
|
||||
assigned_admin_id = Column(BigInteger, nullable=True, index=True)
|
||||
last_message_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
last_message_role = Column(String(16), nullable=True)
|
||||
unread_user_count = Column(Integer, nullable=False, default=0)
|
||||
unread_admin_count = Column(Integer, nullable=False, default=0)
|
||||
admin_last_notified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin_last_emailed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
closed_by_admin_id = Column(BigInteger, nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
messages = relationship(
|
||||
"SupportTicketMessage",
|
||||
back_populates="ticket",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),
|
||||
)
|
||||
|
||||
|
||||
class SupportTicketMessage(Base):
|
||||
__tablename__ = "support_ticket_messages"
|
||||
|
||||
message_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ticket_id = Column(
|
||||
Integer,
|
||||
ForeignKey("support_tickets.ticket_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_role = Column(String(16), nullable=False)
|
||||
author_user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
|
||||
body = Column(Text, nullable=False)
|
||||
is_internal_note = Column(Boolean, nullable=False, default=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
read_by_user_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_by_admin_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
ticket = relationship("SupportTicket", back_populates="messages")
|
||||
author_user = relationship("User")
|
||||
|
||||
|
||||
class PanelSyncStatus(Base):
|
||||
__tablename__ = "panel_sync_status"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user