mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243) Product decisions from the issue thread: 1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes PAGINATED_CATEGORIES and the cache buckets by category, so the board's fan-out stays fixed at 7 parallel requests however many custom statuses a workspace defines — instead of one more request per status added. Adds status_category / status_categories filters to the issue list endpoints, backed by the existing issue_effective_status() SQL function. 3. Archiving retires a status from FUTURE use only. It no longer refuses while issues still carry it: those issues keep their status and keep resolving to their category's behavior (Effective ignores archived_at), while Resolve still rejects the status so nothing new can be assigned to it. The exclusive catalog lock stays — it is what makes "no NEW assignment after archive" exact rather than approximate. Also lands the frontend catalog foundation: packages/core/issue-statuses with useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as a distinct type from the status key and status_category on issue payloads. NOT included, and deliberately: widening IssueStatus to string. That change forces every Record<IssueStatus, …> presentation lookup in packages/views to resolve a key to its category first, and it is the first task of the follow-up PR. Everything here is the groundwork that makes it mechanical. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): restore the status index, make category authoritative, keep archived resolvable Review round 1 on #7084 (MUL-6243): 1. Category filtering wrapped issue.status in issue_effective_status(), which makes the (workspace_id, status) index unusable — and the board switched to it unconditionally, so every workspace paid for it immediately. Categories are now expanded to concrete status keys (issuestatus.ExpandCategories) and filtered with an indexed status = ANY(...). Measured on a 100k-row table with the production index: seq scan cost 27013 -> index-only scan cost 6.07. A workspace with no custom statuses expands each category to exactly its own key, reproducing the pre-feature query. Archived keys are included, since issues left on them still belong in their category's column. 2. status_category was only filled for built-ins, so a custom-status issue arriving over the websocket was silently dropped by addIssueToBuckets and a cross-category update was skipped by the cache coordinator, drifting off-window totals permanently. List, grouped, get, create, update and batch responses now carry an authoritative category via a per-request Resolver (archived included), and the client invalidates instead of no-oping when a category is unresolvable. 3. The catalog was fetched without archived entries, so after archiving a status the issues still on it lost their name and colour and had their category guessed as todo — contradicting the decision that archiving leaves existing issues untouched. Resolution now includes archived entries; activeStatuses / inCategory expose only assignable ones to pickers. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication Review round 2 on #7084 (MUL-6243): 1. Category fill was incomplete and N+1. The create WEBSOCKET payload was unfilled — only the creator's HTTP response was, which is too late for the other tabs the event exists to serve. GetIssue, the open_only list, search, the duplicate-conflict response, the table rows, the GitHub PR update and the onboarding shim were also unfilled. Grouped and batch called the single-response helper per row, so each custom-status row hit the catalog separately. Every exit now goes through newStatusCategoryFiller, a request-scoped closure over ONE Resolver: built-in statuses still cost no query, and a page of custom-status rows costs one catalog read rather than one per row. 2. A status KEY change within one category duplicated the card and inflated the total. patchIssueInBuckets compared patch.status (a key) against loc.status (a bucket CATEGORY), so in_review -> a custom human_review took the cross-bucket path, then deleted from and re-inserted into the same bucket from a pre-delete snapshot. It now resolves the destination category first and compares category to category; same-category changes replace in place and leave the total alone. The destination category is resolved from the patch alone — the merged issue inherits the previous status_category, which would have kept a genuinely moved card in its old column. patchNeedsInvalidation lets the loaded branch of the cache coordinator invalidate instead of silently no-oping on a status it cannot resolve. Also drops the unrelated share_link.sql.go churn from an sqlc version difference, per the review nit. The four cache cases were verified to fail against the previous logic (card duplicated, total +1) before the fix was restored. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): normalize status_category on every cache write; one filler per create Review round 3 on #7084 (MUL-6243): Must-fix — a cached entity kept the STALE status_category after a status change. The server now sends a category on every issue, so a cached entity is {status: "todo", status_category: "todo"}; an optimistic patch carries only {status: "done"}, and a bare {...issue, ...patch} moved the card to the done bucket while the entity still claimed todo. A single update self-heals from the server response, but the batch API returns only {updated: n} and does not refetch bucketed lists, so the inconsistency was permanent — and the next off-window count then decremented the wrong bucket, recreating total drift. normalizeStatusPatch now rewrites status_category to match status before any cache write, applied at applyIssueChange's entry (covering the flat, table and detail spreads), in patchIssueInBuckets, and in the children mutation. An unresolvable custom key drops the inherited category rather than keeping it; patchNeedsInvalidation still routes it to a refetch. Test fixtures now carry status_category for built-ins, matching what the server actually sends — without that the regression was invisible. Non-blocking items also addressed: - CreateIssue builds one filler and shares it between the broadcast payload and the HTTP response, so a custom-status create reads the catalog once. - Background events via IssueToMap now resolve an authoritative category (IssueToMapWithCategory) for the task, issue and autopilot publishers. The channel router keeps plain IssueToMap and says why: it always creates with the built-in "todo", where key IS category. The "one resolver serves a whole page" assertion was REMOVED rather than kept: pg_stat counters lag, so a delta around one request passes even with a per-row filler. Verified that directly before dropping it. The comment now states what is and is not pinned instead of implying coverage that does not exist. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): address review nits - Normalize the single-update children optimistic patch too, matching the batch path. It self-healed on onSettled, but it broke the entity/bucket invariant for the duration of the request. - Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered "without per row queries", which the test explicitly does not assert. - Drop the unrelated gofmt churn in empty_claim_cache.go. - Add a background-event assertion: IssueToMap leaves a custom status uncategorized, IssueToMapWithCategory resolves it — the reason the publishers use the latter. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
413 lines
13 KiB
Go
413 lines
13 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: issue_status.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const archiveIssueStatusEntry = `-- name: ArchiveIssueStatusEntry :one
|
|
UPDATE issue_status SET
|
|
archived_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
AND workspace_id = $2::uuid
|
|
AND is_system = FALSE
|
|
AND archived_at IS NULL
|
|
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
|
|
`
|
|
|
|
type ArchiveIssueStatusEntryParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// Built-ins are excluded by is_system: archiving one would delete its
|
|
// category's behavior definition.
|
|
//
|
|
// Archiving retires a status from future use only. Issues already on it keep
|
|
// it — Effective ignores archived_at, so their behavior is unchanged — while
|
|
// Resolve rejects it, so nothing new can be assigned to it.
|
|
func (q *Queries) ArchiveIssueStatusEntry(ctx context.Context, arg ArchiveIssueStatusEntryParams) (IssueStatus, error) {
|
|
row := q.db.QueryRow(ctx, archiveIssueStatusEntry, arg.ID, arg.WorkspaceID)
|
|
var i IssueStatus
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const countIssuesUsingStatusKey = `-- name: CountIssuesUsingStatusKey :one
|
|
SELECT COUNT(*)::bigint FROM issue
|
|
WHERE workspace_id = $1::uuid
|
|
AND status = $2::text
|
|
`
|
|
|
|
type CountIssuesUsingStatusKeyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// Reported alongside a status so the UI can say how many issues still carry an
|
|
// archived one. NOT an archive precondition: archiving never requires migrating
|
|
// issues off the status.
|
|
func (q *Queries) CountIssuesUsingStatusKey(ctx context.Context, arg CountIssuesUsingStatusKeyParams) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countIssuesUsingStatusKey, arg.WorkspaceID, arg.Key)
|
|
var column_1 int64
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const createIssueStatusEntry = `-- name: CreateIssueStatusEntry :one
|
|
INSERT INTO issue_status (workspace_id, key, name, description, category, color, position)
|
|
VALUES (
|
|
$1::uuid,
|
|
$2::text,
|
|
$3::text,
|
|
$4::text,
|
|
$5::text,
|
|
$6::text,
|
|
COALESCE(
|
|
(SELECT MAX(position) + 1 FROM issue_status
|
|
WHERE workspace_id = $1::uuid
|
|
AND category = $5::text),
|
|
0
|
|
)
|
|
)
|
|
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
|
|
`
|
|
|
|
type CreateIssueStatusEntryParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
// Custom statuses only: is_system is never set here, so the canonical-key and
|
|
// non-archivable CHECK constraints can only ever apply to seeded rows.
|
|
func (q *Queries) CreateIssueStatusEntry(ctx context.Context, arg CreateIssueStatusEntryParams) (IssueStatus, error) {
|
|
row := q.db.QueryRow(ctx, createIssueStatusEntry,
|
|
arg.WorkspaceID,
|
|
arg.Key,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Category,
|
|
arg.Color,
|
|
)
|
|
var i IssueStatus
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteIssueStatusEntriesForWorkspace = `-- name: DeleteIssueStatusEntriesForWorkspace :exec
|
|
DELETE FROM issue_status WHERE workspace_id = $1::uuid
|
|
`
|
|
|
|
// No foreign keys by project rule, so workspace teardown cleans up here.
|
|
func (q *Queries) DeleteIssueStatusEntriesForWorkspace(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteIssueStatusEntriesForWorkspace, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const getIssueStatusEntryByID = `-- name: GetIssueStatusEntryByID :one
|
|
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
|
|
WHERE id = $1::uuid
|
|
AND workspace_id = $2::uuid
|
|
`
|
|
|
|
type GetIssueStatusEntryByIDParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) GetIssueStatusEntryByID(ctx context.Context, arg GetIssueStatusEntryByIDParams) (IssueStatus, error) {
|
|
row := q.db.QueryRow(ctx, getIssueStatusEntryByID, arg.ID, arg.WorkspaceID)
|
|
var i IssueStatus
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getIssueStatusEntryByKey = `-- name: GetIssueStatusEntryByKey :one
|
|
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
|
|
WHERE workspace_id = $1::uuid
|
|
AND key = $2::text
|
|
`
|
|
|
|
type GetIssueStatusEntryByKeyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
func (q *Queries) GetIssueStatusEntryByKey(ctx context.Context, arg GetIssueStatusEntryByKeyParams) (IssueStatus, error) {
|
|
row := q.db.QueryRow(ctx, getIssueStatusEntryByKey, arg.WorkspaceID, arg.Key)
|
|
var i IssueStatus
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listIssueStatusEntries = `-- name: ListIssueStatusEntries :many
|
|
SELECT id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at FROM issue_status
|
|
WHERE workspace_id = $1::uuid
|
|
AND ($2::bool OR archived_at IS NULL)
|
|
ORDER BY
|
|
CASE category
|
|
WHEN 'backlog' THEN 0
|
|
WHEN 'todo' THEN 1
|
|
WHEN 'in_progress' THEN 2
|
|
WHEN 'in_review' THEN 3
|
|
WHEN 'done' THEN 4
|
|
WHEN 'blocked' THEN 5
|
|
WHEN 'cancelled' THEN 6
|
|
ELSE 7
|
|
END,
|
|
position,
|
|
key
|
|
`
|
|
|
|
type ListIssueStatusEntriesParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
IncludeArchived bool `json:"include_archived"`
|
|
}
|
|
|
|
// Ordered by category rank (the historical STATUS_ORDER, so the default board
|
|
// and picker stay pixel-identical for a workspace with no custom statuses),
|
|
// then intra-category position, then key as a stable tiebreak.
|
|
func (q *Queries) ListIssueStatusEntries(ctx context.Context, arg ListIssueStatusEntriesParams) ([]IssueStatus, error) {
|
|
rows, err := q.db.Query(ctx, listIssueStatusEntries, arg.WorkspaceID, arg.IncludeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []IssueStatus{}
|
|
for rows.Next() {
|
|
var i IssueStatus
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listIssueStatusKeysByCategories = `-- name: ListIssueStatusKeysByCategories :many
|
|
SELECT key FROM issue_status
|
|
WHERE workspace_id = $1::uuid
|
|
AND category = ANY($2::text[])
|
|
`
|
|
|
|
type ListIssueStatusKeysByCategoriesParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Categories []string `json:"categories"`
|
|
}
|
|
|
|
// Expands a set of categories to the status keys that belong to them, so a
|
|
// category filter can be applied as an INDEXED `status = ANY(...)` predicate
|
|
// instead of wrapping the column in issue_effective_status(), which makes
|
|
// (workspace_id, status) unusable and forces a full workspace scan.
|
|
//
|
|
// ARCHIVED statuses are included on purpose: archiving retires a status from
|
|
// future assignment but leaves existing issues on it, and those issues must
|
|
// still appear in their category's column.
|
|
func (q *Queries) ListIssueStatusKeysByCategories(ctx context.Context, arg ListIssueStatusKeysByCategoriesParams) ([]string, error) {
|
|
rows, err := q.db.Query(ctx, listIssueStatusKeysByCategories, arg.WorkspaceID, arg.Categories)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []string{}
|
|
for rows.Next() {
|
|
var key string
|
|
if err := rows.Scan(&key); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, key)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockIssueStatusCatalog = `-- name: LockIssueStatusCatalog :exec
|
|
SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':issue_status', 0))
|
|
`
|
|
|
|
// EXCLUSIVE transaction lock over a workspace's status catalog, held by archive
|
|
// so no issue can be written onto a status between the in-use census and the
|
|
// archive itself. pg_advisory_xact_lock (not pg_try_) so a concurrent archive
|
|
// queues instead of failing.
|
|
func (q *Queries) LockIssueStatusCatalog(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockIssueStatusCatalog, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const lockIssueStatusCatalogShared = `-- name: LockIssueStatusCatalogShared :exec
|
|
SELECT pg_advisory_xact_lock_shared(hashtextextended($1::uuid::text || ':issue_status', 0))
|
|
`
|
|
|
|
// SHARED counterpart, taken by a write that puts an issue ON a custom status.
|
|
// Shared holders do not block each other, so concurrent issue writes keep their
|
|
// current throughput; they only block against archive's exclusive lock.
|
|
//
|
|
// Built-in statuses deliberately do NOT take this lock: a built-in can never be
|
|
// archived (enforced by issue_status_system_not_archivable), so there is nothing
|
|
// to race with, and the overwhelmingly common write path stays lock-free.
|
|
func (q *Queries) LockIssueStatusCatalogShared(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockIssueStatusCatalogShared, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const seedIssueStatusEntries = `-- name: SeedIssueStatusEntries :exec
|
|
|
|
INSERT INTO issue_status (workspace_id, key, name, description, category, color, is_system, position)
|
|
VALUES
|
|
($1::uuid, 'backlog', 'Backlog', 'Parked. Assigning an issue here never starts an agent run.', 'backlog', '#6b7280', TRUE, 0),
|
|
($1::uuid, 'todo', 'Todo', 'Queued for work. Moving an issue here starts the assigned agent.', 'todo', '#6b7280', TRUE, 0),
|
|
($1::uuid, 'in_progress', 'In Progress', 'Actively being worked on.', 'in_progress', '#f59e0b', TRUE, 0),
|
|
($1::uuid, 'in_review', 'In Review', 'Work delivered, waiting on human review. Finalizes the autopilot run.', 'in_review', '#22c55e', TRUE, 0),
|
|
($1::uuid, 'done', 'Done', 'Completed.', 'done', '#3b82f6', TRUE, 0),
|
|
($1::uuid, 'blocked', 'Blocked', 'Stalled on an external dependency.', 'blocked', '#ef4444', TRUE, 0),
|
|
($1::uuid, 'cancelled', 'Cancelled', 'Decided not to do.', 'cancelled', '#6b7280', TRUE, 0)
|
|
ON CONFLICT DO NOTHING
|
|
`
|
|
|
|
// Issue status catalog (MUL-6243). Each workspace holds the 7 built-in
|
|
// statuses plus any custom ones. A category's value IS its canonical built-in
|
|
// key, so resolving a custom status to its platform behavior is a plain column
|
|
// read — no mapping table, no second concept.
|
|
// Idempotent seed of the 7 built-ins. Safe to call concurrently from multiple
|
|
// pods during a rolling deploy: the unique (workspace_id, key) index makes a
|
|
// losing racer a no-op rather than an error. Positions are intra-category, and
|
|
// each built-in is the only member of its category at seed time, so all 0.
|
|
func (q *Queries) SeedIssueStatusEntries(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, seedIssueStatusEntries, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const updateIssueStatusEntry = `-- name: UpdateIssueStatusEntry :one
|
|
UPDATE issue_status SET
|
|
name = COALESCE($1, name),
|
|
description = COALESCE($2, description),
|
|
color = COALESCE($3, color),
|
|
position = COALESCE($4, position),
|
|
updated_at = now()
|
|
WHERE id = $5::uuid
|
|
AND workspace_id = $6::uuid
|
|
AND is_system = FALSE
|
|
AND archived_at IS NULL
|
|
RETURNING id, workspace_id, key, name, description, category, color, is_system, position, archived_at, created_at, updated_at
|
|
`
|
|
|
|
type UpdateIssueStatusEntryParams struct {
|
|
Name pgtype.Text `json:"name"`
|
|
Description pgtype.Text `json:"description"`
|
|
Color pgtype.Text `json:"color"`
|
|
Position pgtype.Float8 `json:"position"`
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// key and category are absent on purpose: both are immutable after create.
|
|
// The is_system guard is what enforces "built-in name and color are locked" at
|
|
// the storage layer, so a handler bug cannot rename a built-in.
|
|
func (q *Queries) UpdateIssueStatusEntry(ctx context.Context, arg UpdateIssueStatusEntryParams) (IssueStatus, error) {
|
|
row := q.db.QueryRow(ctx, updateIssueStatusEntry,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Color,
|
|
arg.Position,
|
|
arg.ID,
|
|
arg.WorkspaceID,
|
|
)
|
|
var i IssueStatus
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Key,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.Category,
|
|
&i.Color,
|
|
&i.IsSystem,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|