Changeset 0.35.0 (#209)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -559,6 +559,11 @@ type ChannelStore interface {
|
||||
|
||||
// MergeSettings merges a JSON object into the channel's settings column.
|
||||
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error
|
||||
|
||||
// ── Monitoring (v0.35.0) ──
|
||||
|
||||
// ListByType returns all channels of a given type (e.g. "workflow").
|
||||
ListByType(ctx context.Context, channelType string) ([]models.Channel, error)
|
||||
}
|
||||
|
||||
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.
|
||||
|
||||
@@ -562,12 +562,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5
|
||||
WHERE id = $6
|
||||
`, workflowID, version, stageData, status, time.Now().UTC(), channelID)
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5,
|
||||
stage_entered_at = $6
|
||||
WHERE id = $7
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -577,10 +579,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -592,11 +594,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`, nextStage, stageData, time.Now().UTC(), channelID)
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3,
|
||||
stage_entered_at = $4
|
||||
WHERE id = $5
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -611,9 +615,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
|
||||
`, stage, time.Now().UTC(), channelID)
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2,
|
||||
stage_entered_at = $3 WHERE id = $4
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -908,6 +914,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = $1
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -224,11 +224,13 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules, surface_pkg_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, sla_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, created_at`,
|
||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds,
|
||||
).Scan(&st.ID, &st.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -236,7 +238,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = $1
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -250,7 +252,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
|
||||
&st.HistoryMode, &st.AutoTransition, &transRules,
|
||||
&st.SurfacePkgID, &st.CreatedAt); err != nil {
|
||||
&st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = formTpl
|
||||
@@ -271,10 +273,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
||||
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9,
|
||||
transition_rules = $10, surface_pkg_id = $11
|
||||
transition_rules = $10, surface_pkg_id = $11, sla_seconds = $12
|
||||
WHERE id = $1`,
|
||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID)
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -497,3 +500,34 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
review_comments, created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = rc
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET review_comments = review_comments || $1::jsonb
|
||||
WHERE id = $2
|
||||
`, "["+string(commentJSON)+"]", assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -563,12 +563,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -578,10 +580,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = ? AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -593,11 +595,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -612,9 +616,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
|
||||
`, stage, time.Now().UTC().Format(timeFmt), channelID)
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ?,
|
||||
stage_entered_at = ? WHERE id = ?
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -866,6 +872,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = ?
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -160,11 +160,11 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
surface_pkg_id, sla_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339))
|
||||
st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = ?
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -187,7 +187,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
|
||||
&stg.HistoryMode, &autoTrans, &transRules,
|
||||
&stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil {
|
||||
&stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stg.FormTemplate = json.RawMessage(formTpl)
|
||||
@@ -209,11 +209,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
||||
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?,
|
||||
transition_rules = ?, surface_pkg_id = ?
|
||||
transition_rules = ?, surface_pkg_id = ?, sla_seconds = ?
|
||||
WHERE id = ?`,
|
||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.ID)
|
||||
st.SurfacePkgID, st.SLASeconds, st.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -504,3 +504,44 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc string
|
||||
var claimedAt, completedAt *time.Time
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = ?
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = json.RawMessage(rc)
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite: read, append, write back
|
||||
var existing string
|
||||
err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
_ = json.Unmarshal([]byte(existing), &arr)
|
||||
arr = append(arr, json.RawMessage(commentJSON))
|
||||
updated, _ := json.Marshal(arr)
|
||||
_, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -54,4 +54,19 @@ type WorkflowStore interface {
|
||||
// TryRoundRobin finds the least-recently-assigned team member and claims.
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error)
|
||||
|
||||
// AddReviewComment appends a comment to an assignment's review_comments array.
|
||||
AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error
|
||||
}
|
||||
|
||||
// ReviewComment is a single review comment on a workflow assignment (v0.35.0).
|
||||
type ReviewComment struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ type WorkflowChannelStatus struct {
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"`
|
||||
LastActivityAt *string `json:"last_activity_at"`
|
||||
StageEnteredAt *string `json:"stage_entered_at,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowAssignment is a row from the workflow_assignments table.
|
||||
type WorkflowAssignment struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Stage int `json:"stage"`
|
||||
TeamID string `json:"team_id"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Stage int `json:"stage"`
|
||||
TeamID string `json:"team_id"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
Status string `json:"status"`
|
||||
ReviewComments json.RawMessage `json:"review_comments"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user