diff --git a/internal/audit/sqlc/models.go b/internal/audit/sqlc/models.go index 5c2647f..2215f93 100644 --- a/internal/audit/sqlc/models.go +++ b/internal/audit/sqlc/models.go @@ -21,6 +21,17 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Issue struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -34,6 +45,7 @@ type Issue struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type Notification struct { @@ -73,6 +85,7 @@ type Requirement struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type User struct { @@ -93,3 +106,31 @@ type UserSession struct { LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/infra/notify/sqlc/models.go b/internal/infra/notify/sqlc/models.go index 3be92e1..d93313e 100644 --- a/internal/infra/notify/sqlc/models.go +++ b/internal/infra/notify/sqlc/models.go @@ -21,6 +21,17 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Issue struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -34,6 +45,7 @@ type Issue struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type Notification struct { @@ -73,6 +85,7 @@ type Requirement struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type User struct { @@ -93,3 +106,31 @@ type UserSession struct { LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/project/domain.go b/internal/project/domain.go index 555d95c..31454e1 100644 --- a/internal/project/domain.go +++ b/internal/project/domain.go @@ -77,9 +77,12 @@ type Project struct { } // Requirement 表示一个项目内的需求。Number 在 (project_id) 内自增。 +// WorkspaceID 为 nil 表示未关联任何 workspace;非 nil 时由 Service 层保证 +// 该 workspace 与本 requirement 同 project(Plan 3 引入)。 type Requirement struct { ID uuid.UUID ProjectID uuid.UUID + WorkspaceID *uuid.UUID Number int Title string Description string @@ -93,10 +96,13 @@ type Requirement struct { // Issue 可挂载到某个 Requirement,也可游离(RequirementID == nil)。 // AssigneeID 为 nil 表示未指派。CreatedBy 永远存在(DELETE RESTRICT)。 +// WorkspaceID 为 nil 表示未关联 workspace;非 nil 时 DB 通过复合外键 +// (project_id, workspace_id) 拒绝跨 project 关联(Plan 3 引入)。 type Issue struct { ID uuid.UUID ProjectID uuid.UUID RequirementID *uuid.UUID + WorkspaceID *uuid.UUID Number int Title string Description string @@ -172,18 +178,27 @@ type UpdateProjectInput struct { } // CreateRequirementInput 创建一条需求。OwnerID 为 nil 时由 Service 默认为调用者。 +// WorkspaceID 为 nil 表示不关联任何 workspace;非 nil 时直接落库(FK 由 PG 校验)。 type CreateRequirementInput struct { Title string Description string OwnerID *uuid.UUID + WorkspaceID *uuid.UUID } // UpdateRequirementInput 同 UpdateProjectInput 的 patch 语义。Phase / Status 不在此处修改, // 走专用方法 ChangePhase / Close / Reopen 以便 audit 区分。 +// +// WorkspaceID 走"uuid.Nil sentinel"三态语义(与 RequirementNumber 的 **int 不同): +// +// nil —— 未提供(保持原 workspace_id 不变) +// &uuid.Nil —— 已提供,要求"解绑"(workspace_id 置 NULL) +// &id (非 Nil) —— 已提供,要求关联到指定 workspace type UpdateRequirementInput struct { Title *string Description *string OwnerID *uuid.UUID + WorkspaceID *uuid.UUID } // RequirementFilter 控制 List 时的过滤维度。Phase / Status 留零值("" / Status(""))表示 @@ -196,11 +211,13 @@ type RequirementFilter struct { // CreateIssueInput 创建 Issue。RequirementNumber == nil 表示游离 Issue(不挂载到任何 // Requirement);非 nil 时 Service 会校验 1) 该 Requirement 与目标 Project 同属, // 2) 未关闭。 +// WorkspaceID 为 nil 表示不关联 workspace;非 nil 时 DB 复合 FK 保证同 project。 type CreateIssueInput struct { Title string Description string RequirementNumber *int AssigneeID *uuid.UUID + WorkspaceID *uuid.UUID } // UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态: @@ -210,10 +227,17 @@ type CreateIssueInput struct { // *x != nil —— 已提供,要求"挂载到本项目下 number=**x 的 Requirement" // // HTTP 层用 json.RawMessage 区分 "字段缺省 vs 显式 null vs 数字",再翻译为本三态。 +// +// WorkspaceID 走"uuid.Nil sentinel"三态语义(不再嵌一层指针): +// +// nil —— 未提供(保持原 workspace_id 不变) +// &uuid.Nil —— 已提供,要求"解绑"(workspace_id 置 NULL) +// &id (非 Nil) —— 已提供,要求关联到指定 workspace(DB FK 校验同 project) type UpdateIssueInput struct { Title *string Description *string RequirementNumber **int + WorkspaceID *uuid.UUID } // IssueFilter 控制 List 时的过滤。Requirement 字段三态(与 sqlc requirement_filter diff --git a/internal/project/handler.go b/internal/project/handler.go index 3de8597..ee39d75 100644 --- a/internal/project/handler.go +++ b/internal/project/handler.go @@ -129,6 +129,7 @@ type projectDTO struct { type requirementDTO struct { ID string `json:"id"` ProjectID string `json:"project_id"` + WorkspaceID *string `json:"workspace_id"` Number int `json:"number"` Title string `json:"title"` Description string `json:"description"` @@ -144,6 +145,7 @@ type issueDTO struct { ID string `json:"id"` ProjectID string `json:"project_id"` RequirementID *string `json:"requirement_id"` + WorkspaceID *string `json:"workspace_id"` Number int `json:"number"` Title string `json:"title"` Description string `json:"description"` @@ -177,6 +179,10 @@ func toRequirementDTO(r *Requirement) requirementDTO { CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), } + if r.WorkspaceID != nil { + s := r.WorkspaceID.String() + d.WorkspaceID = &s + } if r.ClosedAt != nil { s := r.ClosedAt.UTC().Format("2006-01-02T15:04:05Z") d.ClosedAt = &s @@ -196,6 +202,10 @@ func toIssueDTO(i *Issue) issueDTO { s := i.RequirementID.String() d.RequirementID = &s } + if i.WorkspaceID != nil { + s := i.WorkspaceID.String() + d.WorkspaceID = &s + } if i.AssigneeID != nil { s := i.AssigneeID.String() d.AssigneeID = &s @@ -332,6 +342,7 @@ type createRequirementReq struct { Title string `json:"title"` Description string `json:"description"` OwnerID *string `json:"owner_id,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` } func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) { @@ -354,6 +365,14 @@ func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) { } in.OwnerID = &uid } + if req.WorkspaceID != nil { + wid, perr := uuid.Parse(*req.WorkspaceID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法")) + return + } + in.WorkspaceID = &wid + } out, err := h.requirements.Create(r.Context(), c, chi.URLParam(r, "slug"), in) if err != nil { writeErr(w, r, err) @@ -411,10 +430,12 @@ func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) { }) } +// updateRequirementReq 用 json.RawMessage 区分 workspace_id 的 "未传"/"显式 null"/"传值"。 type updateRequirementReq struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - OwnerID *string `json:"owner_id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + OwnerID *string `json:"owner_id,omitempty"` + WorkspaceID json.RawMessage `json:"workspace_id,omitempty"` } func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) { @@ -442,6 +463,10 @@ func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) { } in.OwnerID = &uid } + if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil { + writeErr(w, r, err) + return + } out, err := h.requirements.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in) if err != nil { writeErr(w, r, err) @@ -450,6 +475,32 @@ func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out)) } +// parseWorkspaceIDPatch 把 PATCH 请求里的 json.RawMessage workspace_id 字段 +// 翻译为 *uuid.UUID 三态: +// - 字段缺省(len == 0):保留 *dst = nil +// - 显式 null:*dst = &uuid.Nil(解绑 sentinel) +// - UUID 字符串:解析并赋值;解析失败返回 invalid_input +func parseWorkspaceIDPatch(raw json.RawMessage, dst **uuid.UUID) error { + if len(raw) == 0 { + return nil + } + if string(raw) == "null" { + nilUUID := uuid.Nil + *dst = &nilUUID + return nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return errs.New(errs.CodeInvalidInput, "workspace_id 非法") + } + wid, perr := uuid.Parse(s) + if perr != nil { + return errs.New(errs.CodeInvalidInput, "workspace_id 非法") + } + *dst = &wid + return nil +} + type phaseReq struct { Phase string `json:"phase"` } @@ -511,6 +562,7 @@ type createIssueReq struct { Description string `json:"description"` RequirementNumber *int `json:"requirement_number,omitempty"` AssigneeID *string `json:"assignee_id,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` } func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) { @@ -533,6 +585,14 @@ func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) { } in.AssigneeID = &uid } + if req.WorkspaceID != nil { + wid, perr := uuid.Parse(*req.WorkspaceID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "workspace_id 非法")) + return + } + in.WorkspaceID = &wid + } out, err := h.issues.Create(r.Context(), c, chi.URLParam(r, "slug"), in) if err != nil { writeErr(w, r, err) @@ -607,6 +667,7 @@ type updateIssueReq struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` RequirementNumber json.RawMessage `json:"requirement_number,omitempty"` + WorkspaceID json.RawMessage `json:"workspace_id,omitempty"` } func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) { @@ -641,6 +702,10 @@ func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) { in.RequirementNumber = &pn } } + if err := parseWorkspaceIDPatch(req.WorkspaceID, &in.WorkspaceID); err != nil { + writeErr(w, r, err) + return + } out, err := h.issues.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in) if err != nil { writeErr(w, r, err) diff --git a/internal/project/handler_test.go b/internal/project/handler_test.go index 95f7e2b..a5c7dbc 100644 --- a/internal/project/handler_test.go +++ b/internal/project/handler_test.go @@ -114,3 +114,102 @@ func TestHandler_SlugTaken_Returns409(t *testing.T) { require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, "project_slug_taken", body["code"]) } + +// decodeBodyMap 解析 200 响应里的 JSON 对象。 +func decodeBodyMap(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + t.Cleanup(func() { _ = resp.Body.Close() }) + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + return body +} + +// TestHandler_Issue_PATCH_WorkspaceID_TriState 验证 PATCH /issues/{n} 的 +// workspace_id 三态:缺失=不变 / null=解绑 / UUID 字符串=关联 / 非法=400。 +func TestHandler_Issue_PATCH_WorkspaceID_TriState(t *testing.T) { + owner := uuid.New() + tok := "tok" + _, h := mountFullHandler(t, tok, owner) + checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) + wsID := uuid.New() + resp := doJSON(t, h, "POST", "/api/v1/projects/demo/issues", tok, map[string]any{"title": "I", "workspace_id": wsID.String()}) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, http.StatusCreated, resp.StatusCode) + createBody := decodeBodyMap(t, resp) + require.Equal(t, wsID.String(), createBody["workspace_id"]) + + // 1) 缺失字段:不变 + resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"title": "I-new"}) + require.Equal(t, http.StatusOK, resp2.StatusCode) + body2 := decodeBodyMap(t, resp2) + require.Equal(t, wsID.String(), body2["workspace_id"]) + + // 2) UUID 字符串:关联到新 workspace + wsB := uuid.New() + resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": wsB.String()}) + require.Equal(t, http.StatusOK, resp3.StatusCode) + body3 := decodeBodyMap(t, resp3) + require.Equal(t, wsB.String(), body3["workspace_id"]) + + // 3) 显式 null:解绑(用 raw JSON 才能传 null,不能用 map[string]any{"workspace_id": nil}, + // 因为 map nil 也会被序列化成 null,这里直接拼字符串保证准确) + rawNull := bytes.NewBufferString(`{"workspace_id": null}`) + req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/issues/1", rawNull) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + resp4 := rec.Result() + require.Equal(t, http.StatusOK, resp4.StatusCode) + body4 := decodeBodyMap(t, resp4) + require.Nil(t, body4["workspace_id"]) + + // 4) 非法字符串:400 + resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/issues/1", tok, map[string]any{"workspace_id": "not-a-uuid"}) + t.Cleanup(func() { _ = resp5.Body.Close() }) + require.Equal(t, http.StatusBadRequest, resp5.StatusCode) +} + +// TestHandler_Requirement_PATCH_WorkspaceID_TriState 验证 PATCH /requirements/{n} +// 的 workspace_id 三态。 +func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) { + owner := uuid.New() + tok := "tok" + _, h := mountFullHandler(t, tok, owner) + checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) + wsID := uuid.New() + resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R", "workspace_id": wsID.String()}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + createBody := decodeBodyMap(t, resp) + require.Equal(t, wsID.String(), createBody["workspace_id"]) + + // 1) 缺失字段:不变 + resp2 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"title": "R-new"}) + require.Equal(t, http.StatusOK, resp2.StatusCode) + body2 := decodeBodyMap(t, resp2) + require.Equal(t, wsID.String(), body2["workspace_id"]) + + // 2) UUID 字符串:关联到新 workspace + wsB := uuid.New() + resp3 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": wsB.String()}) + require.Equal(t, http.StatusOK, resp3.StatusCode) + body3 := decodeBodyMap(t, resp3) + require.Equal(t, wsB.String(), body3["workspace_id"]) + + // 3) 显式 null:解绑 + rawNull := bytes.NewBufferString(`{"workspace_id": null}`) + req := httptest.NewRequest("PATCH", "/api/v1/projects/demo/requirements/1", rawNull) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + resp4 := rec.Result() + require.Equal(t, http.StatusOK, resp4.StatusCode) + body4 := decodeBodyMap(t, resp4) + require.Nil(t, body4["workspace_id"]) + + // 4) 非法字符串:400 + resp5 := doJSON(t, h, "PATCH", "/api/v1/projects/demo/requirements/1", tok, map[string]any{"workspace_id": "not-a-uuid"}) + t.Cleanup(func() { _ = resp5.Body.Close() }) + require.Equal(t, http.StatusBadRequest, resp5.StatusCode) +} diff --git a/internal/project/issue_service.go b/internal/project/issue_service.go index 37ebe82..08f6c51 100644 --- a/internal/project/issue_service.go +++ b/internal/project/issue_service.go @@ -61,7 +61,8 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre } in1 := &Issue{ ID: uuid.New(), ProjectID: p.ID, RequirementID: reqID, - Title: in.Title, Description: in.Description, + WorkspaceID: in.WorkspaceID, + Title: in.Title, Description: in.Description, AssigneeID: in.AssigneeID, CreatedBy: c.UserID, } out, err := s.createWithRetry(ctx, in1) @@ -72,6 +73,9 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre if reqID != nil { meta["requirement_id"] = reqID.String() } + if in.WorkspaceID != nil { + meta["workspace_id"] = in.WorkspaceID.String() + } s.recordAudit(ctx, c, "issue.create", out.ID.String(), meta) return out, nil } @@ -165,11 +169,34 @@ func (s *issueService) Update(ctx context.Context, c Caller, slug string, number } } + // workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联) + nextWS := cur.WorkspaceID + if in.WorkspaceID != nil { + if *in.WorkspaceID == uuid.Nil { + nextWS = nil + } else { + v := *in.WorkspaceID + nextWS = &v + } + auditFrom := "" + if cur.WorkspaceID != nil { + auditFrom = cur.WorkspaceID.String() + } + auditTo := "" + if nextWS != nil { + auditTo = nextWS.String() + } + if auditFrom != auditTo { + changed["from_workspace"] = auditFrom + changed["to_workspace"] = auditTo + } + } + if len(changed) == 0 { return cur, nil } - out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID) + out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID, nextWS) if err != nil { return nil, err } diff --git a/internal/project/issue_service_test.go b/internal/project/issue_service_test.go index b187e32..d41325a 100644 --- a/internal/project/issue_service_test.go +++ b/internal/project/issue_service_test.go @@ -114,3 +114,72 @@ func TestIssueService_Update_BlockedWhenProjectArchived(t *testing.T) { require.True(t, ok) require.Equal(t, errs.CodeProjectArchived, ae.Code) } + +func TestIssueService_Create_WithWorkspaceID(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, _, spy := newIssueSvc(repo) + caller := Caller{UserID: owner} + + wsID := uuid.New() + out, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{ + Title: "I", WorkspaceID: &wsID, + }) + require.NoError(t, err) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsID, *out.WorkspaceID) + + // 再 Get 一次确认持久化 + got, err := issueSvc.Get(context.Background(), caller, "demo", out.Number) + require.NoError(t, err) + require.NotNil(t, got.WorkspaceID) + require.Equal(t, wsID, *got.WorkspaceID) + + // audit metadata 包含 workspace_id + require.NotEmpty(t, spy.entries) + last := spy.entries[len(spy.entries)-1] + require.Equal(t, "issue.create", last.Action) + require.Equal(t, wsID.String(), last.Metadata["workspace_id"]) +} + +func TestIssueService_Update_WorkspaceID_TriState(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + wsA := uuid.New() + i, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{ + Title: "I", WorkspaceID: &wsA, + }) + require.NoError(t, err) + require.NotNil(t, i.WorkspaceID) + require.Equal(t, wsA, *i.WorkspaceID) + + // 1) nil → 不变(用 Title 强制 changed 非空,走持久化路径,验证 nextWS 不被错误清空) + newTitle := "I-renamed" + out, err := issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{Title: &newTitle}) + require.NoError(t, err) + require.Equal(t, newTitle, out.Title) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsA, *out.WorkspaceID) + + // 2) &id(非 Nil)→ 关联到新 workspace + wsB := uuid.New() + out, err = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{ + WorkspaceID: &wsB, + }) + require.NoError(t, err) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsB, *out.WorkspaceID) + + // 3) &uuid.Nil → 显式解绑 + nilUUID := uuid.Nil + out, err = issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{ + WorkspaceID: &nilUUID, + }) + require.NoError(t, err) + require.Nil(t, out.WorkspaceID) +} diff --git a/internal/project/project_service_test.go b/internal/project/project_service_test.go index 357cac4..324b8cf 100644 --- a/internal/project/project_service_test.go +++ b/internal/project/project_service_test.go @@ -193,6 +193,7 @@ func (r *fakeRepo) UpdateRequirement(_ context.Context, in *Requirement) (*Requi return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } cur.Title, cur.Description, cur.OwnerID = in.Title, in.Description, in.OwnerID + cur.WorkspaceID = in.WorkspaceID cur.UpdatedAt = time.Now() return r.cloneReq(cur), nil } @@ -310,7 +311,7 @@ func (r *fakeRepo) ListIssuesByRequirement(_ context.Context, requirementID uuid return out, nil } -func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) { +func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) { r.mu.Lock() defer r.mu.Unlock() cur, ok := r.issues[id] @@ -318,6 +319,7 @@ func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, descr return nil, errs.New(errs.CodeNotFound, "issue 不存在") } cur.Title, cur.Description, cur.RequirementID = title, description, requirementID + cur.WorkspaceID = workspaceID cur.UpdatedAt = time.Now() return r.cloneIssue(cur), nil } diff --git a/internal/project/queries/issues.sql b/internal/project/queries/issues.sql index 2126658..1768f6c 100644 --- a/internal/project/queries/issues.sql +++ b/internal/project/queries/issues.sql @@ -1,22 +1,22 @@ -- name: CreateIssue :one -INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by) +INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id) VALUES ( $1, $2, $3, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2), - $4, $5, $6, $7 + $4, $5, $6, $7, $8 ) RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; -- name: GetIssueByNumber :one SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE project_id = $1 AND number = $2; -- name: ListIssues :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE project_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) @@ -30,7 +30,7 @@ ORDER BY number DESC; -- name: ListIssuesByRequirement :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE requirement_id = $1 ORDER BY number DESC; @@ -40,17 +40,18 @@ UPDATE issues SET title = $2, description = $3, requirement_id = $4, + workspace_id = $5, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; -- name: UpdateIssueAssignee :one UPDATE issues SET assignee_id = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; -- name: CloseIssue :exec UPDATE issues SET status = 'closed', closed_at = now(), updated_at = now() diff --git a/internal/project/queries/requirements.sql b/internal/project/queries/requirements.sql index 38ac99a..5a0bc14 100644 --- a/internal/project/queries/requirements.sql +++ b/internal/project/queries/requirements.sql @@ -1,27 +1,27 @@ -- name: CreateRequirement :one -INSERT INTO requirements (id, project_id, number, title, description, owner_id) +INSERT INTO requirements (id, project_id, number, title, description, owner_id, workspace_id) VALUES ( $1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM requirements WHERE project_id = $2), - $3, $4, $5 + $3, $4, $5, $6 ) RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at; + closed_at, created_at, updated_at, workspace_id; -- name: GetRequirementByNumber :one SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE project_id = $1 AND number = $2; -- name: GetRequirementByID :one SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE id = $1; -- name: ListRequirements :many SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE project_id = $1 AND (sqlc.narg('phase')::text IS NULL OR phase = sqlc.narg('phase')) @@ -30,20 +30,21 @@ ORDER BY number DESC; -- name: UpdateRequirement :one UPDATE requirements -SET title = $2, - description = $3, - owner_id = $4, - updated_at = now() +SET title = $2, + description = $3, + owner_id = $4, + workspace_id = $5, + updated_at = now() WHERE id = $1 RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at; + closed_at, created_at, updated_at, workspace_id; -- name: UpdateRequirementPhase :one UPDATE requirements SET phase = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at; + closed_at, created_at, updated_at, workspace_id; -- name: CloseRequirement :exec UPDATE requirements diff --git a/internal/project/repository.go b/internal/project/repository.go index 9f21a98..cfac32f 100644 --- a/internal/project/repository.go +++ b/internal/project/repository.go @@ -46,7 +46,7 @@ type Repository interface { GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error) - UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) + UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) CloseIssue(ctx context.Context, id uuid.UUID) error ReopenIssue(ctx context.Context, id uuid.UUID) error @@ -59,6 +59,16 @@ func IsUniqueViolation(err error) bool { return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation } +// IsForeignKeyViolation 报告 err 是否为 PG 外键约束冲突。常见场景: +// - issues.(project_id, workspace_id) 复合 FK 在跨 project 关联时被 PG 拒绝; +// - requirements.workspace_id 关联到不存在的 workspace 时被 PG 拒绝。 +// +// Repository 层据此把错误翻成 invalid_input(HTTP 400),避免泄漏 5xx。 +func IsForeignKeyViolation(err error) bool { + var pg *pgconn.PgError + return errors.As(err, &pg) && pg.Code == pgerrcode.ForeignKeyViolation +} + type pgRepo struct{ q *projectsqlc.Queries } // NewPostgresRepository 用现有的 pgxpool 构造 Repository。 @@ -200,6 +210,7 @@ func rowToRequirement(row projectsqlc.Requirement) *Requirement { return &Requirement{ ID: fromPgUUID(row.ID), ProjectID: fromPgUUID(row.ProjectID), + WorkspaceID: fromPgUUIDPtr(row.WorkspaceID), Number: int(row.Number), Title: row.Title, Description: row.Description, @@ -219,11 +230,15 @@ func (r *pgRepo) CreateRequirement(ctx context.Context, in *Requirement) (*Requi Title: in.Title, Description: in.Description, OwnerID: toPgUUID(in.OwnerID), + WorkspaceID: toPgUUIDPtr(in.WorkspaceID), }) if err != nil { if IsUniqueViolation(err) { return nil, err // service 层据此重试一次 } + if IsForeignKeyViolation(err) { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") + } return nil, errs.Wrap(err, errs.CodeInternal, "create requirement") } return rowToRequirement(row), nil @@ -285,11 +300,15 @@ func (r *pgRepo) UpdateRequirement(ctx context.Context, in *Requirement) (*Requi Title: in.Title, Description: in.Description, OwnerID: toPgUUID(in.OwnerID), + WorkspaceID: toPgUUIDPtr(in.WorkspaceID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } + if IsForeignKeyViolation(err) { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") + } return nil, errs.Wrap(err, errs.CodeInternal, "update requirement") } return rowToRequirement(row), nil @@ -330,6 +349,7 @@ func rowToIssue(row projectsqlc.Issue) *Issue { ID: fromPgUUID(row.ID), ProjectID: fromPgUUID(row.ProjectID), RequirementID: fromPgUUIDPtr(row.RequirementID), + WorkspaceID: fromPgUUIDPtr(row.WorkspaceID), Number: int(row.Number), Title: row.Title, Description: row.Description, @@ -351,11 +371,15 @@ func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) { Description: in.Description, AssigneeID: toPgUUIDPtr(in.AssigneeID), CreatedBy: toPgUUID(in.CreatedBy), + WorkspaceID: toPgUUIDPtr(in.WorkspaceID), }) if err != nil { if IsUniqueViolation(err) { return nil, err } + if IsForeignKeyViolation(err) { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") + } return nil, errs.Wrap(err, errs.CodeInternal, "create issue") } return rowToIssue(row), nil @@ -420,17 +444,21 @@ func (r *pgRepo) ListIssuesByRequirement(ctx context.Context, requirementID uuid return out, nil } -func (r *pgRepo) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) { +func (r *pgRepo) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) { row, err := r.q.UpdateIssueCore(ctx, projectsqlc.UpdateIssueCoreParams{ ID: toPgUUID(id), Title: title, Description: description, RequirementID: toPgUUIDPtr(requirementID), + WorkspaceID: toPgUUIDPtr(workspaceID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } + if IsForeignKeyViolation(err) { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") + } return nil, errs.Wrap(err, errs.CodeInternal, "update issue core") } return rowToIssue(row), nil diff --git a/internal/project/requirement_service.go b/internal/project/requirement_service.go index 26be8b3..79456c6 100644 --- a/internal/project/requirement_service.go +++ b/internal/project/requirement_service.go @@ -65,16 +65,21 @@ func (s *requirementService) Create(ctx context.Context, c Caller, slug string, } in1 := &Requirement{ ID: uuid.New(), ProjectID: p.ID, - Title: in.Title, Description: in.Description, + WorkspaceID: in.WorkspaceID, + Title: in.Title, Description: in.Description, OwnerID: owner, } out, err := s.createWithRetry(ctx, in1) if err != nil { return nil, err } - s.recordAudit(ctx, c, "requirement.create", out.ID.String(), map[string]any{ + meta := map[string]any{ "project_id": p.ID.String(), "number": out.Number, "title": out.Title, - }) + } + if in.WorkspaceID != nil { + meta["workspace_id"] = in.WorkspaceID.String() + } + s.recordAudit(ctx, c, "requirement.create", out.ID.String(), meta) return out, nil } @@ -137,6 +142,27 @@ func (s *requirementService) Update(ctx context.Context, c Caller, slug string, r.OwnerID = *in.OwnerID changed["owner_id"] = in.OwnerID.String() } + // workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联) + if in.WorkspaceID != nil { + auditFrom := "" + if r.WorkspaceID != nil { + auditFrom = r.WorkspaceID.String() + } + if *in.WorkspaceID == uuid.Nil { + r.WorkspaceID = nil + } else { + v := *in.WorkspaceID + r.WorkspaceID = &v + } + auditTo := "" + if r.WorkspaceID != nil { + auditTo = r.WorkspaceID.String() + } + if auditFrom != auditTo { + changed["from_workspace"] = auditFrom + changed["to_workspace"] = auditTo + } + } if len(changed) == 0 { return r, nil } diff --git a/internal/project/requirement_service_test.go b/internal/project/requirement_service_test.go index 184aaec..ca4e73d 100644 --- a/internal/project/requirement_service_test.go +++ b/internal/project/requirement_service_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) @@ -99,3 +100,78 @@ func TestRequirementService_Reopen_AllowsPhaseAgain(t *testing.T) { require.NoError(t, err) require.Equal(t, PhaseDone, out.Phase) } + +func TestRequirementService_Create_WithWorkspaceID(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, spy := newReqSvc(repo) + caller := Caller{UserID: owner} + + wsID := uuid.New() + out, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{ + Title: "R", WorkspaceID: &wsID, + }) + require.NoError(t, err) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsID, *out.WorkspaceID) + + // 再 Get 一次确认持久化 + got, err := svc.Get(context.Background(), caller, "demo", out.Number) + require.NoError(t, err) + require.NotNil(t, got.WorkspaceID) + require.Equal(t, wsID, *got.WorkspaceID) + + // audit metadata 包含 workspace_id + require.NotEmpty(t, spy.entries) + var createEntry *audit.Entry + for i := range spy.entries { + if spy.entries[i].Action == "requirement.create" { + createEntry = &spy.entries[i] + } + } + require.NotNil(t, createEntry) + require.Equal(t, wsID.String(), createEntry.Metadata["workspace_id"]) +} + +func TestRequirementService_Update_WorkspaceID_TriState(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newReqSvc(repo) + caller := Caller{UserID: owner} + + wsA := uuid.New() + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{ + Title: "R", WorkspaceID: &wsA, + }) + require.NoError(t, err) + require.NotNil(t, r.WorkspaceID) + require.Equal(t, wsA, *r.WorkspaceID) + + // 1) nil → 不变(结合一个其它 patch 字段触发 update) + newTitle := "R2" + out, err := svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{ + Title: &newTitle, + }) + require.NoError(t, err) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsA, *out.WorkspaceID) + + // 2) &id(非 Nil)→ 关联到新 workspace + wsB := uuid.New() + out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{ + WorkspaceID: &wsB, + }) + require.NoError(t, err) + require.NotNil(t, out.WorkspaceID) + require.Equal(t, wsB, *out.WorkspaceID) + + // 3) &uuid.Nil → 显式解绑 + nilUUID := uuid.Nil + out, err = svc.Update(context.Background(), caller, "demo", r.Number, UpdateRequirementInput{ + WorkspaceID: &nilUUID, + }) + require.NoError(t, err) + require.Nil(t, out.WorkspaceID) +} diff --git a/internal/project/sqlc/issues.sql.go b/internal/project/sqlc/issues.sql.go index a9e0f27..c02a0ed 100644 --- a/internal/project/sqlc/issues.sql.go +++ b/internal/project/sqlc/issues.sql.go @@ -22,14 +22,14 @@ func (q *Queries) CloseIssue(ctx context.Context, id pgtype.UUID) error { } const createIssue = `-- name: CreateIssue :one -INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by) +INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id) VALUES ( $1, $2, $3, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2), - $4, $5, $6, $7 + $4, $5, $6, $7, $8 ) RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id ` type CreateIssueParams struct { @@ -40,6 +40,7 @@ type CreateIssueParams struct { Description string `json:"description"` AssigneeID pgtype.UUID `json:"assignee_id"` CreatedBy pgtype.UUID `json:"created_by"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -51,6 +52,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.Description, arg.AssigneeID, arg.CreatedBy, + arg.WorkspaceID, ) var i Issue err := row.Scan( @@ -66,13 +68,14 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE project_id = $1 AND number = $2 ` @@ -98,13 +101,14 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } const listIssues = `-- name: ListIssues :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE project_id = $1 AND ($2::text IS NULL OR status = $2) @@ -153,6 +157,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ); err != nil { return nil, err } @@ -166,7 +171,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue const listIssuesByRequirement = `-- name: ListIssuesByRequirement :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id FROM issues WHERE requirement_id = $1 ORDER BY number DESC @@ -194,6 +199,7 @@ func (q *Queries) ListIssuesByRequirement(ctx context.Context, requirementID pgt &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ); err != nil { return nil, err } @@ -220,7 +226,7 @@ UPDATE issues SET assignee_id = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id ` type UpdateIssueAssigneeParams struct { @@ -244,6 +250,7 @@ func (q *Queries) UpdateIssueAssignee(ctx context.Context, arg UpdateIssueAssign &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } @@ -253,10 +260,11 @@ UPDATE issues SET title = $2, description = $3, requirement_id = $4, + workspace_id = $5, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id ` type UpdateIssueCoreParams struct { @@ -264,6 +272,7 @@ type UpdateIssueCoreParams struct { Title string `json:"title"` Description string `json:"description"` RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams) (Issue, error) { @@ -272,6 +281,7 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams arg.Title, arg.Description, arg.RequirementID, + arg.WorkspaceID, ) var i Issue err := row.Scan( @@ -287,6 +297,7 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go index 08ae849..04f6a37 100644 --- a/internal/project/sqlc/models.go +++ b/internal/project/sqlc/models.go @@ -21,6 +21,17 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Issue struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -34,6 +45,7 @@ type Issue struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type Notification struct { @@ -73,6 +85,7 @@ type Requirement struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type User struct { @@ -93,3 +106,31 @@ type UserSession struct { LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/project/sqlc/requirements.sql.go b/internal/project/sqlc/requirements.sql.go index f0ac2b7..6ac217b 100644 --- a/internal/project/sqlc/requirements.sql.go +++ b/internal/project/sqlc/requirements.sql.go @@ -23,14 +23,14 @@ func (q *Queries) CloseRequirement(ctx context.Context, id pgtype.UUID) error { } const createRequirement = `-- name: CreateRequirement :one -INSERT INTO requirements (id, project_id, number, title, description, owner_id) +INSERT INTO requirements (id, project_id, number, title, description, owner_id, workspace_id) VALUES ( $1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM requirements WHERE project_id = $2), - $3, $4, $5 + $3, $4, $5, $6 ) RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id ` type CreateRequirementParams struct { @@ -39,6 +39,7 @@ type CreateRequirementParams struct { Title string `json:"title"` Description string `json:"description"` OwnerID pgtype.UUID `json:"owner_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementParams) (Requirement, error) { @@ -48,6 +49,7 @@ func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementPa arg.Title, arg.Description, arg.OwnerID, + arg.WorkspaceID, ) var i Requirement err := row.Scan( @@ -62,13 +64,14 @@ func (q *Queries) CreateRequirement(ctx context.Context, arg CreateRequirementPa &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } const getRequirementByID = `-- name: GetRequirementByID :one SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE id = $1 ` @@ -87,13 +90,14 @@ func (q *Queries) GetRequirementByID(ctx context.Context, id pgtype.UUID) (Requi &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } const getRequirementByNumber = `-- name: GetRequirementByNumber :one SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE project_id = $1 AND number = $2 ` @@ -118,13 +122,14 @@ func (q *Queries) GetRequirementByNumber(ctx context.Context, arg GetRequirement &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } const listRequirements = `-- name: ListRequirements :many SELECT id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id FROM requirements WHERE project_id = $1 AND ($2::text IS NULL OR phase = $2) @@ -159,6 +164,7 @@ func (q *Queries) ListRequirements(ctx context.Context, arg ListRequirementsPara &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ); err != nil { return nil, err } @@ -183,13 +189,14 @@ func (q *Queries) ReopenRequirement(ctx context.Context, id pgtype.UUID) error { const updateRequirement = `-- name: UpdateRequirement :one UPDATE requirements -SET title = $2, - description = $3, - owner_id = $4, - updated_at = now() +SET title = $2, + description = $3, + owner_id = $4, + workspace_id = $5, + updated_at = now() WHERE id = $1 RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id ` type UpdateRequirementParams struct { @@ -197,6 +204,7 @@ type UpdateRequirementParams struct { Title string `json:"title"` Description string `json:"description"` OwnerID pgtype.UUID `json:"owner_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementParams) (Requirement, error) { @@ -205,6 +213,7 @@ func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementPa arg.Title, arg.Description, arg.OwnerID, + arg.WorkspaceID, ) var i Requirement err := row.Scan( @@ -219,6 +228,7 @@ func (q *Queries) UpdateRequirement(ctx context.Context, arg UpdateRequirementPa &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } @@ -228,7 +238,7 @@ UPDATE requirements SET phase = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, number, title, description, phase, status, owner_id, - closed_at, created_at, updated_at + closed_at, created_at, updated_at, workspace_id ` type UpdateRequirementPhaseParams struct { @@ -251,6 +261,7 @@ func (q *Queries) UpdateRequirementPhase(ctx context.Context, arg UpdateRequirem &i.ClosedAt, &i.CreatedAt, &i.UpdatedAt, + &i.WorkspaceID, ) return i, err } diff --git a/internal/user/sqlc/models.go b/internal/user/sqlc/models.go index ba3b4ad..9fbf7cf 100644 --- a/internal/user/sqlc/models.go +++ b/internal/user/sqlc/models.go @@ -21,6 +21,17 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Issue struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -34,6 +45,7 @@ type Issue struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type Notification struct { @@ -73,6 +85,7 @@ type Requirement struct { ClosedAt pgtype.Timestamptz `json:"closed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type User struct { @@ -93,3 +106,31 @@ type UserSession struct { LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +}