package handler import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) // ProjectResourceResponse is the JSON shape returned by the project resource API. type ProjectResourceResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` WorkspaceID string `json:"workspace_id"` ResourceType string `json:"resource_type"` ResourceRef json.RawMessage `json:"resource_ref"` Label *string `json:"label"` Position int32 `json:"position"` CreatedAt string `json:"created_at"` CreatedBy *string `json:"created_by"` } func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse { ref := json.RawMessage(r.ResourceRef) if len(ref) == 0 { ref = json.RawMessage("{}") } return ProjectResourceResponse{ ID: uuidToString(r.ID), ProjectID: uuidToString(r.ProjectID), WorkspaceID: uuidToString(r.WorkspaceID), ResourceType: r.ResourceType, ResourceRef: ref, Label: textToPtr(r.Label), Position: r.Position, CreatedAt: timestampToString(r.CreatedAt), CreatedBy: uuidToPtr(r.CreatedBy), } } // CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources. type CreateProjectResourceRequest struct { ResourceType string `json:"resource_type"` ResourceRef json.RawMessage `json:"resource_ref"` Label *string `json:"label"` Position *int32 `json:"position"` } // validateAndNormalizeResourceRef checks the payload for a known resource_type. // New types are added here without schema migration; unknown types are rejected // at the API boundary so a typo can't slip through and produce a resource the // daemon/UI doesn't understand. func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) { if len(ref) == 0 { return nil, errors.New("resource_ref is required") } switch resourceType { case "github_repo": return validateGithubRepoRef(ref) default: return nil, fmt.Errorf("unknown resource_type %q", resourceType) } } type githubRepoRef struct { URL string `json:"url"` DefaultBranchHint string `json:"default_branch_hint,omitempty"` } func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) { var payload githubRepoRef if err := json.Unmarshal(ref, &payload); err != nil { return nil, fmt.Errorf("invalid github_repo payload: %w", err) } payload.URL = strings.TrimSpace(payload.URL) if payload.URL == "" { return nil, errors.New("github_repo: url is required") } if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, errors.New("github_repo: url must be a valid http(s) URL") } payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint) out, err := json.Marshal(payload) if err != nil { return nil, err } return out, nil } // loadProjectForResource resolves the project, enforces workspace ownership, // and returns its DB row. Used by all project_resource handlers. func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) { projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id") if !ok { return db.Project{}, false } wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id") if !ok { return db.Project{}, false } project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: projectUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "project not found") return db.Project{}, false } return project, true } // ListProjectResources returns the resources attached to a project. func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } resources, err := h.Queries.ListProjectResources(r.Context(), project.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list project resources") return } resp := make([]ProjectResourceResponse, len(resources)) for i, res := range resources { resp[i] = projectResourceToResponse(res) } writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)}) } // CreateProjectResource attaches a new resource to a project. func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } userID, ok := requireUserID(w, r) if !ok { return } var req CreateProjectResourceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } req.ResourceType = strings.TrimSpace(req.ResourceType) if req.ResourceType == "" { writeError(w, http.StatusBadRequest, "resource_type is required") return } normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var label pgtype.Text if req.Label != nil && strings.TrimSpace(*req.Label) != "" { label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true} } var position int32 if req.Position != nil { position = *req.Position } else { // Append after existing resources. count, _ := h.Queries.CountProjectResources(r.Context(), project.ID) position = int32(count) } creator, _ := h.parseUserUUIDOrZero(userID) resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{ ProjectID: project.ID, WorkspaceID: project.WorkspaceID, ResourceType: req.ResourceType, ResourceRef: normalizedRef, Label: label, Position: position, CreatedBy: creator, }) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "this resource is already attached to the project") return } writeError(w, http.StatusInternalServerError, "failed to create project resource") return } resp := projectResourceToResponse(resource) h.publish( protocol.EventProjectResourceCreated, uuidToString(project.WorkspaceID), "member", userID, map[string]any{"resource": resp, "project_id": uuidToString(project.ID)}, ) writeJSON(w, http.StatusCreated, resp) } // DeleteProjectResource removes a resource from a project. func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) { project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id")) if !ok { return } resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id") if !ok { return } userID, ok := requireUserID(w, r) if !ok { return } resource, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{ ID: resourceUUID, WorkspaceID: project.WorkspaceID, }) if err != nil { writeError(w, http.StatusNotFound, "project resource not found") return } if uuidToString(resource.ProjectID) != uuidToString(project.ID) { writeError(w, http.StatusNotFound, "project resource not found") return } if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete project resource") return } h.publish( protocol.EventProjectResourceDeleted, uuidToString(project.WorkspaceID), "member", userID, map[string]any{ "project_id": uuidToString(project.ID), "resource_id": uuidToString(resource.ID), }, ) w.WriteHeader(http.StatusNoContent) } // parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a // zero value on any error so the caller can store NULL for created_by when the // authenticated principal is not a workspace member (e.g. internal-server use). func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) { if userID == "" { return pgtype.UUID{}, false } u, err := parseUUIDLoose(userID) if err != nil { return pgtype.UUID{}, false } return u, true } // parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util // into a tiny one-off helper. Keep the body minimal. func parseUUIDLoose(s string) (pgtype.UUID, error) { var u pgtype.UUID if err := u.Scan(s); err != nil { return pgtype.UUID{}, err } return u, nil } // listProjectResourcesForProject is a small helper used by the daemon claim // handler to attach project resources to outgoing tasks. func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource { if !projectID.Valid { return nil } rows, err := h.Queries.ListProjectResources(ctx, projectID) if err != nil { return nil } return rows }