feat(market): track user points change
This commit is contained in:
parent
42e3571fab
commit
a264336bd8
@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
||||
return obj.get_cas_groups_display()
|
||||
|
||||
get_cas_groups_display.short_description = "CAS Groups"
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customuser",
|
||||
name="points",
|
||||
field=models.IntegerField(default=1000),
|
||||
),
|
||||
]
|
||||
@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from market.models import Market, MarketOption, UserBet
|
||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||
|
||||
|
||||
class MarketOptionInline(admin.TabularInline):
|
||||
model = MarketOption
|
||||
extra = 1
|
||||
fields = ["text", "position"]
|
||||
fields = ["text"]
|
||||
|
||||
|
||||
@admin.register(Market)
|
||||
class MarketAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "type", "status", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "type", "created_at"]
|
||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "created_at"]
|
||||
search_fields = ["uuid", "title"]
|
||||
readonly_fields = [
|
||||
"uuid",
|
||||
@ -23,7 +23,7 @@ class MarketAdmin(admin.ModelAdmin):
|
||||
inlines = [MarketOptionInline]
|
||||
fieldsets = (
|
||||
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||
("Configuration", {"fields": ["type", "end_date"]}),
|
||||
("Configuration", {"fields": ["end_date"]}),
|
||||
("Status", {"fields": ["status", "winning_option"]}),
|
||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||
)
|
||||
@ -45,7 +45,7 @@ class MarketAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(MarketOption)
|
||||
class MarketOptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["text", "market", "position"]
|
||||
list_display = ["text", "market"]
|
||||
list_filter = ["market"]
|
||||
search_fields = ["uuid", "text", "market__title"]
|
||||
readonly_fields = ["uuid"]
|
||||
@ -54,7 +54,7 @@ class MarketOptionAdmin(admin.ModelAdmin):
|
||||
@admin.register(UserBet)
|
||||
class UserBetAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "option", "amount", "created_at"]
|
||||
list_filter = ["created_at", "option__market"]
|
||||
list_filter = ["user", "created_at", "option__market"]
|
||||
search_fields = ["uuid", "user__username", "option__text"]
|
||||
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||
|
||||
@ -63,3 +63,17 @@ class UserBetAdmin(admin.ModelAdmin):
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(UserPointChange)
|
||||
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||
list_filter = ["user", "reason", "created_at", "market"]
|
||||
search_fields = ["uuid", "user__username", "market__title"]
|
||||
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@ -2,8 +2,11 @@ from typing import List
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Sum, Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db import transaction
|
||||
|
||||
from market.models import Market, MarketOption, UserBet
|
||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||
from market.schemas import (
|
||||
MarketListSchema,
|
||||
ResolveMarketSchema,
|
||||
@ -18,7 +21,13 @@ router = Router(tags=["market"])
|
||||
@router.get("/", response=List[MarketListSchema])
|
||||
def list_markets(request):
|
||||
"""List all markets."""
|
||||
return Market.objects.prefetch_related("options").all()
|
||||
markets = Market.objects.all()
|
||||
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||
options_queryset = MarketOption.objects.annotate(
|
||||
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||
).order_by("-total_bets", "text")
|
||||
|
||||
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||
|
||||
|
||||
@router.get("/user/bets", response=List[UserBetSchema])
|
||||
@ -27,9 +36,11 @@ def list_user_bets(request):
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
return UserBet.objects.filter(user=request.user).select_related(
|
||||
"option__market"
|
||||
).prefetch_related("option__market__options")
|
||||
return (
|
||||
UserBet.objects.filter(user=request.user)
|
||||
.select_related("option__market")
|
||||
.prefetch_related("option__market__options")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{market_uuid}/actions/close")
|
||||
@ -59,6 +70,60 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||
market.winning_option = winning_option
|
||||
market.status = Market.Status.RESOLVED
|
||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||
|
||||
# Calculate and distribute winnings
|
||||
all_bets = list(
|
||||
UserBet.objects.filter(option__market=market).select_related("user")
|
||||
)
|
||||
|
||||
# Calculate total pot
|
||||
total_pot = sum(bet.amount for bet in all_bets)
|
||||
if total_pot == 0:
|
||||
return market
|
||||
|
||||
# Separate winning and losing bets
|
||||
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||
|
||||
total_winning = sum(bet.amount for bet in winning_bets)
|
||||
|
||||
point_changes = []
|
||||
users_to_update = []
|
||||
|
||||
with transaction.atomic():
|
||||
# Award payouts to winners
|
||||
if total_winning > 0:
|
||||
for bet in winning_bets:
|
||||
payout = round(bet.amount / total_winning * total_pot)
|
||||
bet.user.points += payout
|
||||
users_to_update.append(bet.user)
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user=bet.user,
|
||||
market=market,
|
||||
amount=payout,
|
||||
reason=UserPointChange.Reason.BET_WON,
|
||||
)
|
||||
)
|
||||
|
||||
# Record losing bets (points already deducted)
|
||||
for bet in losing_bets:
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user=bet.user,
|
||||
market=market,
|
||||
amount=-bet.amount,
|
||||
reason=UserPointChange.Reason.BET_LOST,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk update users
|
||||
for user in users_to_update:
|
||||
user.save(update_fields=["points"])
|
||||
|
||||
# Bulk create point changes
|
||||
UserPointChange.objects.bulk_create(point_changes)
|
||||
|
||||
return market
|
||||
|
||||
|
||||
@ -78,10 +143,11 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||
raise HttpError(400, "Market is not open for betting")
|
||||
|
||||
# Check if user already has a bet on a different option in this market
|
||||
existing_bet_on_market = UserBet.objects.filter(
|
||||
user=request.user,
|
||||
option__market=market
|
||||
).exclude(option=option).first()
|
||||
existing_bet_on_market = (
|
||||
UserBet.objects.filter(user=request.user, option__market=market)
|
||||
.exclude(option=option)
|
||||
.first()
|
||||
)
|
||||
if existing_bet_on_market:
|
||||
raise HttpError(400, "You can only bet on one option per market")
|
||||
|
||||
@ -90,10 +156,27 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||
if existing_bet and payload.amount < existing_bet.amount:
|
||||
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||
|
||||
# Calculate delta (amount to deduct from user's points)
|
||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||
|
||||
# Check if user has enough points
|
||||
if request.user.points < delta:
|
||||
raise HttpError(400, "Insufficient points for this bet")
|
||||
|
||||
user_bet, created = UserBet.objects.update_or_create(
|
||||
user=request.user,
|
||||
option=option,
|
||||
defaults={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# Deduct points and record the change
|
||||
request.user.points -= delta
|
||||
request.user.save(update_fields=["points"])
|
||||
UserPointChange.objects.create(
|
||||
user=request.user,
|
||||
market=market,
|
||||
amount=-delta,
|
||||
reason=UserPointChange.Reason.BET_PLACED,
|
||||
)
|
||||
|
||||
return user_bet
|
||||
|
||||
73
polylan_submitter/market/migrations/0002_userpointchange.py
Normal file
73
polylan_submitter/market/migrations/0002_userpointchange.py
Normal file
@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserPointChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("amount", models.IntegerField()),
|
||||
(
|
||||
"reason",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("bet_placed", "Bet Placed"),
|
||||
("bet_won", "Bet Won"),
|
||||
("bet_lost", "Bet Lost"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"market",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to="market.market",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["user", "-created_at"],
|
||||
name="market_user_user_id_631ba9_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0002_userpointchange"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="market",
|
||||
name="type",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0003_remove_market_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="marketoption",
|
||||
options={"ordering": ["text"]},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="marketoption",
|
||||
name="unique_market_option_position",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="marketoption",
|
||||
name="market_mark_market__8679ce_idx",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="marketoption",
|
||||
name="position",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="marketoption",
|
||||
index=models.Index(
|
||||
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="marketoption",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("market", "text"), name="unique_market_option_text"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -12,10 +12,6 @@ class BaseModel(models.Model):
|
||||
|
||||
|
||||
class Market(BaseModel):
|
||||
class Type(models.TextChoices):
|
||||
YES_NO = "yes_no", "Yes/No"
|
||||
MULTIPLE = "multiple", "Multiple Choice"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPEN = "open", "Open"
|
||||
CLOSED = "closed", "Closed"
|
||||
@ -23,7 +19,6 @@ class Market(BaseModel):
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
type = models.CharField(max_length=10, choices=Type.choices, default=Type.YES_NO)
|
||||
status = models.CharField(
|
||||
max_length=10, choices=Status.choices, default=Status.OPEN
|
||||
)
|
||||
@ -51,18 +46,17 @@ class Market(BaseModel):
|
||||
class MarketOption(BaseModel):
|
||||
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||
text = models.CharField(max_length=255)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["position"]
|
||||
ordering = ["text"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["market", "position"],
|
||||
name="unique_market_option_position",
|
||||
fields=["market", "text"],
|
||||
name="unique_market_option_text",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["market", "position"]),
|
||||
models.Index(fields=["market"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
@ -91,3 +85,28 @@ class UserBet(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||
|
||||
|
||||
class UserPointChange(BaseModel):
|
||||
class Reason(models.TextChoices):
|
||||
BET_PLACED = "bet_placed", "Bet Placed"
|
||||
BET_WON = "bet_won", "Bet Won"
|
||||
BET_LOST = "bet_lost", "Bet Lost"
|
||||
|
||||
user = models.ForeignKey(
|
||||
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
market = models.ForeignKey(
|
||||
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
amount = models.IntegerField()
|
||||
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||
|
||||
@ -8,9 +8,9 @@ from pydantic import field_serializer, model_validator
|
||||
class MarketOptionSchema(Schema):
|
||||
uuid: UUID
|
||||
text: str
|
||||
position: int
|
||||
total_bets: int = 0
|
||||
|
||||
@field_serializer('uuid')
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
@ -19,14 +19,13 @@ class MarketListSchema(Schema):
|
||||
uuid: UUID
|
||||
title: str
|
||||
description: str
|
||||
type: str
|
||||
status: str
|
||||
end_date: datetime
|
||||
created_at: datetime
|
||||
options: List[MarketOptionSchema]
|
||||
winning_option: Optional[MarketOptionSchema] = None
|
||||
|
||||
@field_serializer('uuid')
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
@ -47,13 +46,13 @@ class UserBetSchema(Schema):
|
||||
option: MarketOptionSchema
|
||||
market: Optional[MarketListSchema] = None
|
||||
|
||||
@field_serializer('uuid')
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'option') and hasattr(data.option, 'market'):
|
||||
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||
data.market = data.option.market
|
||||
return data
|
||||
|
||||
@ -1,2 +1 @@
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@ -978,10 +978,6 @@ export type MarketListSchema = {
|
||||
* Description
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Type
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Status
|
||||
*/
|
||||
@ -1014,9 +1010,9 @@ export type MarketOptionSchema = {
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Position
|
||||
* Total Bets
|
||||
*/
|
||||
position: number;
|
||||
total_bets?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { polylanSubmitterApiGetUserInfo, marketApiCreateBet, marketApiListUserBets } from "../api";
|
||||
import type { Market } from "../types";
|
||||
import type { Market, MarketOption } from "../types";
|
||||
import type { UserInfoOut, UserBetSchema } from "../api/types.gen";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -53,6 +53,21 @@ const canBet = computed(() => {
|
||||
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
|
||||
});
|
||||
|
||||
const totalPot = computed(() => {
|
||||
return props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0);
|
||||
});
|
||||
|
||||
const getMultiplier = (option: MarketOption) => {
|
||||
if (option.total_bets === 0) return 0;
|
||||
return totalPot.value / option.total_bets;
|
||||
};
|
||||
|
||||
const getPotentialGain = (option: MarketOption) => {
|
||||
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
|
||||
const multiplier = getMultiplier(option);
|
||||
return Math.round(existingBet.value.amount * multiplier);
|
||||
};
|
||||
|
||||
const placeBet = async () => {
|
||||
if (!selectedOption.value || !betAmount.value) return;
|
||||
|
||||
@ -75,7 +90,14 @@ const placeBet = async () => {
|
||||
await loadUserBets();
|
||||
betAmount.value = 0;
|
||||
} else {
|
||||
error.value = String(response.error) || "Failed to place bet";
|
||||
const err = response.error as any;
|
||||
if (typeof err === 'object' && err?.detail) {
|
||||
error.value = err.detail;
|
||||
} else if (typeof err === 'string') {
|
||||
error.value = err;
|
||||
} else {
|
||||
error.value = "Failed to place bet";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = "Error placing bet";
|
||||
@ -145,10 +167,18 @@ onMounted(async () => {
|
||||
]">
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<span class="label-text font-medium">{{ option.text }}</span>
|
||||
<span v-if="existingBet && existingBet.option.uuid === option.uuid"
|
||||
class="badge badge-primary badge-sm w-fit">
|
||||
Your bet: {{ existingBet.amount }} pts
|
||||
</span>
|
||||
<div class="text-xs text-base-content/60">
|
||||
<div>Pool: {{ option.total_bets }} pts</div>
|
||||
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
|
||||
</div>
|
||||
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
|
||||
<span class="badge badge-primary badge-sm w-fit">
|
||||
Your bet: {{ existingBet.amount }} pts
|
||||
</span>
|
||||
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
||||
Potential: {{ getPotentialGain(option) }} pts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="radio" :value="option.uuid" v-model="selectedOption" class="radio radio-primary"
|
||||
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)" />
|
||||
|
||||
@ -173,14 +173,13 @@ export interface TournamentPuzzleResults {
|
||||
export interface MarketOption {
|
||||
uuid: string
|
||||
text: string
|
||||
position: number
|
||||
total_bets: number
|
||||
}
|
||||
|
||||
export interface Market {
|
||||
uuid: string
|
||||
title: string
|
||||
description: string
|
||||
type: 'yes_no' | 'multiple'
|
||||
status: 'open' | 'closed' | 'resolved'
|
||||
end_date: string
|
||||
created_at: string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user