Replace status_id with in_progress boolean field

- Remove task_statuses lookup table and StatusID foreign key
- Add InProgress boolean field to Task model
- Add database migration (005_replace_status_with_in_progress)
- Update all handlers, services, and repositories
- Update admin frontend to display in_progress as checkbox/boolean
- Remove Task Statuses tab from admin lookups page
- Update tests to use InProgress instead of StatusID
- Task categorization now uses InProgress for kanban column assignment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 20:48:16 -06:00
parent cb250f108b
commit c5b0225422
43 changed files with 353 additions and 753 deletions

View File

@@ -32,7 +32,8 @@ func TestIntegration_ContractorSharingFlow(t *testing.T) {
var residenceResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &residenceResp)
require.NoError(t, err)
residenceCID := residenceResp["id"].(float64)
residenceData := residenceResp["data"].(map[string]interface{})
residenceCID := residenceData["id"].(float64)
// ========== User A shares residence C with User B ==========
// Generate share code
@@ -191,7 +192,8 @@ func TestIntegration_ContractorAccessWithoutResidenceShare(t *testing.T) {
var residenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp)
residenceID := residenceResp["id"].(float64)
residenceData := residenceResp["data"].(map[string]interface{})
residenceID := residenceData["id"].(float64)
// User A creates a contractor tied to the residence (NOT shared with User B)
contractorBody := map[string]interface{}{
@@ -235,9 +237,10 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken)
require.Equal(t, http.StatusCreated, w.Code)
var residenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp)
residenceID := residenceResp["id"].(float64)
var residenceResp2 map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp2)
residenceData2 := residenceResp2["data"].(map[string]interface{})
residenceID := residenceData2["id"].(float64)
// Share with User B
w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, userAToken)
@@ -259,9 +262,9 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, userAToken)
require.Equal(t, http.StatusCreated, w.Code)
var contractorResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &contractorResp)
contractorID := contractorResp["id"].(float64)
var contractorResp3 map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &contractorResp3)
contractorID3 := contractorResp3["id"].(float64)
// User B (with access) can update the contractor
// Note: Must include residence_id to keep it tied to the residence
@@ -269,7 +272,7 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
"name": "Updated by User B",
"residence_id": uint(residenceID),
}
w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody, userBToken)
w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID3), updateBody, userBToken)
assert.Equal(t, http.StatusOK, w.Code, "User B should be able to update contractor in shared residence")
// User C (without access) cannot update the contractor
@@ -277,15 +280,15 @@ func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) {
"name": "Hacked by User C",
"residence_id": uint(residenceID),
}
w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody2, userCToken)
w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID3), updateBody2, userCToken)
assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to update contractor")
// User C cannot delete the contractor
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userCToken)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID3), nil, userCToken)
assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to delete contractor")
// User B (with access) can delete the contractor
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userBToken)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID3), nil, userBToken)
assert.Equal(t, http.StatusOK, w.Code, "User B should be able to delete contractor in shared residence")
}

View File

@@ -125,7 +125,6 @@ func setupIntegrationTest(t *testing.T) *TestApp {
api.GET("/task-categories", taskHandler.GetCategories)
api.GET("/task-priorities", taskHandler.GetPriorities)
api.GET("/task-statuses", taskHandler.GetStatuses)
api.GET("/task-frequencies", taskHandler.GetFrequencies)
}
@@ -334,10 +333,11 @@ func TestIntegration_ResidenceFlow(t *testing.T) {
var createResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &createResp)
require.NoError(t, err)
residenceID := createResp["id"].(float64)
createData := createResp["data"].(map[string]interface{})
residenceID := createData["id"].(float64)
assert.NotZero(t, residenceID)
assert.Equal(t, "My House", createResp["name"])
assert.True(t, createResp["is_primary"].(bool))
assert.Equal(t, "My House", createData["name"])
assert.True(t, createData["is_primary"].(bool))
// 2. Get the residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token)
@@ -368,8 +368,9 @@ func TestIntegration_ResidenceFlow(t *testing.T) {
var updateResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &updateResp)
require.NoError(t, err)
assert.Equal(t, "My Updated House", updateResp["name"])
assert.Equal(t, "Dallas", updateResp["city"])
updateData := updateResp["data"].(map[string]interface{})
assert.Equal(t, "My Updated House", updateData["name"])
assert.Equal(t, "Dallas", updateData["city"])
// 5. Delete the residence (returns 200 with message, not 204)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, token)
@@ -396,7 +397,8 @@ func TestIntegration_ResidenceSharingFlow(t *testing.T) {
var createResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &createResp)
residenceID := createResp["id"].(float64)
createData := createResp["data"].(map[string]interface{})
residenceID := createData["id"].(float64)
// Other user cannot access initially
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken)
@@ -448,7 +450,8 @@ func TestIntegration_TaskFlow(t *testing.T) {
var residenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp)
residenceID := uint(residenceResp["id"].(float64))
residenceData := residenceResp["data"].(map[string]interface{})
residenceID := uint(residenceData["id"].(float64))
// 1. Create a task
taskBody := map[string]interface{}{
@@ -461,9 +464,10 @@ func TestIntegration_TaskFlow(t *testing.T) {
var taskResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskResp)
taskID := taskResp["id"].(float64)
taskData := taskResp["data"].(map[string]interface{})
taskID := taskData["id"].(float64)
assert.NotZero(t, taskID)
assert.Equal(t, "Fix leaky faucet", taskResp["title"])
assert.Equal(t, "Fix leaky faucet", taskData["title"])
// 2. Get the task
w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, token)
@@ -477,9 +481,10 @@ func TestIntegration_TaskFlow(t *testing.T) {
w = app.makeAuthenticatedRequest(t, "PUT", "/api/tasks/"+formatID(taskID), updateBody, token)
assert.Equal(t, http.StatusOK, w.Code)
var updateResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &updateResp)
assert.Equal(t, "Fix kitchen faucet", updateResp["title"])
var taskUpdateResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskUpdateResp)
taskUpdateData := taskUpdateResp["data"].(map[string]interface{})
assert.Equal(t, "Fix kitchen faucet", taskUpdateData["title"])
// 4. Mark as in progress
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/mark-in-progress", nil, token)
@@ -487,9 +492,8 @@ func TestIntegration_TaskFlow(t *testing.T) {
var progressResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &progressResp)
task := progressResp["task"].(map[string]interface{})
status := task["status"].(map[string]interface{})
assert.Equal(t, "In Progress", status["name"])
progressData := progressResp["data"].(map[string]interface{})
assert.True(t, progressData["in_progress"].(bool))
// 5. Complete the task
completionBody := map[string]interface{}{
@@ -501,9 +505,10 @@ func TestIntegration_TaskFlow(t *testing.T) {
var completionResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &completionResp)
completionID := completionResp["id"].(float64)
completionData := completionResp["data"].(map[string]interface{})
completionID := completionData["id"].(float64)
assert.NotZero(t, completionID)
assert.Equal(t, "Fixed the faucet", completionResp["notes"])
assert.Equal(t, "Fixed the faucet", completionData["notes"])
// 6. List completions
w = app.makeAuthenticatedRequest(t, "GET", "/api/completions?task_id="+formatID(taskID), nil, token)
@@ -515,8 +520,8 @@ func TestIntegration_TaskFlow(t *testing.T) {
var archiveResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &archiveResp)
archivedTask := archiveResp["task"].(map[string]interface{})
assert.True(t, archivedTask["is_archived"].(bool))
archivedData := archiveResp["data"].(map[string]interface{})
assert.True(t, archivedData["is_archived"].(bool))
// 8. Unarchive the task
w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/unarchive", nil, token)
@@ -528,8 +533,8 @@ func TestIntegration_TaskFlow(t *testing.T) {
var cancelResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &cancelResp)
cancelledTask := cancelResp["task"].(map[string]interface{})
assert.True(t, cancelledTask["is_cancelled"].(bool))
cancelledData := cancelResp["data"].(map[string]interface{})
assert.True(t, cancelledData["is_cancelled"].(bool))
// 10. Delete the task (returns 200 with message, not 204)
w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token)
@@ -547,7 +552,8 @@ func TestIntegration_TasksByResidenceKanban(t *testing.T) {
var residenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp)
residenceID := uint(residenceResp["id"].(float64))
residenceData := residenceResp["data"].(map[string]interface{})
residenceID := uint(residenceData["id"].(float64))
// Create multiple tasks
for i := 1; i <= 3; i++ {
@@ -592,7 +598,6 @@ func TestIntegration_LookupEndpoints(t *testing.T) {
{"residence types", "/api/residence-types"},
{"task categories", "/api/task-categories"},
{"task priorities", "/api/task-priorities"},
{"task statuses", "/api/task-statuses"},
{"task frequencies", "/api/task-frequencies"},
}
@@ -633,7 +638,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) {
var residenceResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &residenceResp)
residenceID := residenceResp["id"].(float64)
residenceData := residenceResp["data"].(map[string]interface{})
residenceID := residenceData["id"].(float64)
// User1 creates a task
taskBody := map[string]interface{}{
@@ -645,7 +651,8 @@ func TestIntegration_CrossUserAccessDenied(t *testing.T) {
var taskResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &taskResp)
taskID := taskResp["id"].(float64)
taskData := taskResp["data"].(map[string]interface{})
taskID := taskData["id"].(float64)
// User2 cannot access User1's residence
w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, user2Token)
@@ -693,7 +700,12 @@ func TestIntegration_ResponseStructure(t *testing.T) {
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Verify all expected fields are present
// Response is wrapped with "data" and "summary"
data := resp["data"].(map[string]interface{})
_, hasSummary := resp["summary"]
assert.True(t, hasSummary, "Expected 'summary' field in response")
// Verify all expected fields are present in data
expectedFields := []string{
"id", "owner_id", "name", "street_address", "city",
"state_province", "postal_code", "country",
@@ -701,13 +713,13 @@ func TestIntegration_ResponseStructure(t *testing.T) {
}
for _, field := range expectedFields {
_, exists := resp[field]
assert.True(t, exists, "Expected field %s to be present", field)
_, exists := data[field]
assert.True(t, exists, "Expected field %s to be present in data", field)
}
// Check that nullable fields can be null
assert.Nil(t, resp["bedrooms"])
assert.Nil(t, resp["bathrooms"])
assert.Nil(t, data["bedrooms"])
assert.Nil(t, data["bathrooms"])
}
// ============ Helper Functions ============