opencode: delete method for stacks

This commit is contained in:
2025-08-25 15:26:13 +01:00
parent 3a182fc49b
commit 70161da3ed
7 changed files with 341 additions and 74 deletions

View File

@ -133,29 +133,29 @@ func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error) {
jsonAiRequest, err := json.Marshal(req)
if err != nil {
return AgentResponse{}, fmt.Errorf("Could not format JSON", err)
return AgentResponse{}, fmt.Errorf("Could not format JSON: %w", err)
}
httpRequest, err := client.getRequest(jsonAiRequest)
if err != nil {
return AgentResponse{}, fmt.Errorf("Could not get request", err)
return AgentResponse{}, fmt.Errorf("Could not get request: %w", err)
}
resp, err := client.Do(httpRequest)
if err != nil {
return AgentResponse{}, fmt.Errorf("Could not send request", err)
return AgentResponse{}, fmt.Errorf("Could not send request: %w", err)
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return AgentResponse{}, fmt.Errorf("Could not read body", err)
return AgentResponse{}, fmt.Errorf("Could not read body: %w", err)
}
agentResponse := AgentResponse{}
err = json.Unmarshal(response, &agentResponse)
if err != nil {
return AgentResponse{}, fmt.Errorf("Could not unmarshal response, response: %s", string(response), err)
return AgentResponse{}, fmt.Errorf("Could not unmarshal response, response: %s: %w", string(response), err)
}
if len(agentResponse.Choices) != 1 {

View File

@ -354,6 +354,163 @@ func TestAllRoutes(t *testing.T) {
t.Errorf("Expected status 400 for invalid ID, got %d", resp.StatusCode)
}
})
t.Run("Delete stack without authentication", func(t *testing.T) {
fakeUUID := uuid.New()
resp := tc.makeRequest(t, "DELETE", "/stacks/"+fakeUUID.String(), "", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status 401 for unauthenticated delete, got %d", resp.StatusCode)
}
})
t.Run("Delete stack with invalid ID", func(t *testing.T) {
resp := tc.makeRequest(t, "DELETE", "/stacks/invalid-id", stackUser.Token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400 for invalid ID, got %d", resp.StatusCode)
}
})
t.Run("Delete non-existent stack", func(t *testing.T) {
fakeUUID := uuid.New()
resp := tc.makeRequest(t, "DELETE", "/stacks/"+fakeUUID.String(), stackUser.Token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400 for non-existent stack, got %d", resp.StatusCode)
}
})
t.Run("Create and delete stack successfully", func(t *testing.T) {
// First create a stack
stackData := map[string]string{
"title": "Stack to Delete",
"fields": "name,description,value",
}
resp := tc.makeJSONRequest(t, "POST", "/stacks/", stackUser.Token, stackData)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Failed to create stack for deletion test, got %d", resp.StatusCode)
return
}
// Get the list of stacks to find the created stack ID
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
var stacks []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
t.Errorf("Failed to decode stacks response: %v", err)
resp.Body.Close()
return
}
resp.Body.Close()
if len(stacks) == 0 {
t.Errorf("No stacks found after creation")
return
}
// Find the stack we just created
var stackToDelete map[string]interface{}
for _, stack := range stacks {
if name, ok := stack["Name"].(string); ok && name == "Stack to Delete" {
stackToDelete = stack
break
}
}
if stackToDelete == nil {
t.Errorf("Could not find created stack")
return
}
stackID, ok := stackToDelete["ID"].(string)
if !ok {
t.Errorf("Stack ID not found or not a string")
return
}
// Now delete the stack
resp = tc.makeRequest(t, "DELETE", "/stacks/"+stackID, stackUser.Token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 for successful delete, got %d", resp.StatusCode)
}
// Verify the stack is gone by trying to get it again
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
defer resp.Body.Close()
var stacksAfterDelete []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&stacksAfterDelete); err != nil {
t.Errorf("Failed to decode stacks response after delete: %v", err)
return
}
// Check that the deleted stack is no longer in the list
for _, stack := range stacksAfterDelete {
if id, ok := stack["ID"].(string); ok && id == stackID {
t.Errorf("Stack still exists after deletion")
return
}
}
})
t.Run("Delete stack belonging to different user", func(t *testing.T) {
// Create a stack with stackUser
stackData := map[string]string{
"title": "Other User's Stack",
"fields": "name,description,value",
}
resp := tc.makeJSONRequest(t, "POST", "/stacks/", stackUser.Token, stackData)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Failed to create stack for ownership test, got %d", resp.StatusCode)
return
}
// Get the stack ID
resp = tc.makeRequest(t, "GET", "/stacks/", stackUser.Token, nil)
var stacks []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil {
t.Errorf("Failed to decode stacks response: %v", err)
resp.Body.Close()
return
}
resp.Body.Close()
var stackID string
for _, stack := range stacks {
if name, ok := stack["Name"].(string); ok && name == "Other User's Stack" {
if id, ok := stack["ID"].(string); ok {
stackID = id
break
}
}
}
if stackID == "" {
t.Errorf("Could not find created stack ID")
return
}
// Try to delete the stack with a different user (imageUser)
resp = tc.makeRequest(t, "DELETE", "/stacks/"+stackID, imageUser.Token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400 when deleting another user's stack, got %d", resp.StatusCode)
}
})
})
t.Run("Image Routes", func(t *testing.T) {

View File

@ -1,78 +1,14 @@
package main
import (
"database/sql"
"fmt"
"net/http"
"os"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/auth"
"screenmark/screenmark/images"
"screenmark/screenmark/models"
"screenmark/screenmark/stacks"
ourmiddleware "screenmark/screenmark/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
)
type TestAiClient struct {
ImageInfo client.ImageMessageContent
}
func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (client.ImageMessageContent, error) {
return client.ImageInfo, nil
}
func setupRouter(db *sql.DB) chi.Router {
imageModel := models.NewImageModel(db)
stackModel := models.NewListModel(db)
stackHandler := stacks.CreateStackHandler(db)
authHandler := auth.CreateAuthHandler(db)
imageHandler := images.CreateImageHandler(db)
notifier := NewNotifier[Notification](10)
// Only start event listeners if not in test environment
if os.Getenv("GO_TEST_ENVIRONMENT") != "true" {
// TODO: should extract these into a notification manager
// And actually make them the same code.
// The events are basically the same.
go ListenNewImageEvents(db)
go ListenProcessingImageStatus(db, imageModel, &notifier)
go ListenNewStackEvents(db)
go ListenProcessingStackStatus(db, stackModel, &notifier)
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(ourmiddleware.CorsMiddleware)
r.Route("/stacks", stackHandler.CreateRoutes)
r.Route("/auth", authHandler.CreateRoutes)
r.Route("/images", imageHandler.CreateRoutes)
r.Route("/notifications", func(r chi.Router) {
r.Use(ourmiddleware.GetUserIdFromUrl)
r.Get("/", CreateEventsHandler(&notifier))
})
logWriter := DatabaseWriter{
dbPool: db,
}
r.Route("/logs", createLogHandler(&logWriter))
return r
}
func main() {
err := godotenv.Load()
if err != nil {

View File

@ -38,7 +38,7 @@ type UserProcessingImage struct {
func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.Image) (model.UserImagesToProcess, error) {
tx, err := m.dbPool.BeginTx(ctx, nil)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Failed to begin transaction", err)
return model.UserImagesToProcess{}, fmt.Errorf("Failed to begin transaction: %w", err)
}
insertImageStmt := Image.
@ -49,7 +49,7 @@ func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.I
insertedImage := model.Image{}
err = insertImageStmt.QueryContext(ctx, tx, &insertedImage)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert/query new image. SQL %s.", insertImageStmt.DebugSql(), err)
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert/query new image. SQL %s: %w", insertImageStmt.DebugSql(), err)
}
stmt := UserImagesToProcess.
@ -60,7 +60,7 @@ func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.I
userImage := model.UserImagesToProcess{}
err = stmt.QueryContext(ctx, tx, &userImage)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert user_image", err)
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert user_image: %w", err)
}
err = tx.Commit()

View File

@ -247,6 +247,64 @@ func (m ListModel) SaveProcessing(ctx context.Context, userID uuid.UUID, title s
return err
}
// ========================================
// DELETE methods
// ========================================
func (m ListModel) Delete(ctx context.Context, listID uuid.UUID, userID uuid.UUID) error {
// First verify the list belongs to the user
checkOwnershipStmt := Lists.
SELECT(Lists.ID).
WHERE(Lists.ID.EQ(UUID(listID)).AND(Lists.UserID.EQ(UUID(userID))))
var existingList model.Lists
err := checkOwnershipStmt.QueryContext(ctx, m.dbPool, &existingList)
if err != nil {
return fmt.Errorf("could not verify list ownership: %w", err)
}
// Start a transaction to ensure all deletions happen atomically
tx, err := m.dbPool.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("could not start transaction: %w", err)
}
defer tx.Rollback()
// Delete in reverse order of dependencies:
// 1. Delete schema items first
deleteSchemaItemsStmt := SchemaItems.DELETE().
WHERE(SchemaItems.SchemaID.IN(
Schemas.SELECT(Schemas.ID).
WHERE(Schemas.ListID.EQ(UUID(listID))),
))
_, err = deleteSchemaItemsStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete schema items: %w", err)
}
// 2. Delete schemas
deleteSchemasStmt := Schemas.DELETE().WHERE(Schemas.ListID.EQ(UUID(listID)))
_, err = deleteSchemasStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete schemas: %w", err)
}
// 3. Delete the list itself
deleteListStmt := Lists.DELETE().WHERE(Lists.ID.EQ(UUID(listID)))
_, err = deleteListStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete list: %w", err)
}
// Commit the transaction
err = tx.Commit()
if err != nil {
return fmt.Errorf("could not commit transaction: %w", err)
}
return nil
}
func NewListModel(db *sql.DB) ListModel {
return ListModel{dbPool: db}
}

71
backend/router.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"database/sql"
"os"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/auth"
"screenmark/screenmark/images"
"screenmark/screenmark/models"
"screenmark/screenmark/stacks"
ourmiddleware "screenmark/screenmark/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type TestAiClient struct {
ImageInfo client.ImageMessageContent
}
func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (client.ImageMessageContent, error) {
return client.ImageInfo, nil
}
func setupRouter(db *sql.DB) chi.Router {
imageModel := models.NewImageModel(db)
stackModel := models.NewListModel(db)
stackHandler := stacks.CreateStackHandler(db)
authHandler := auth.CreateAuthHandler(db)
imageHandler := images.CreateImageHandler(db)
notifier := NewNotifier[Notification](10)
// Only start event listeners if not in test environment
if os.Getenv("GO_TEST_ENVIRONMENT") != "true" {
// TODO: should extract these into a notification manager
// And actually make them the same code.
// The events are basically the same.
go ListenNewImageEvents(db)
go ListenProcessingImageStatus(db, imageModel, &notifier)
go ListenNewStackEvents(db)
go ListenProcessingStackStatus(db, stackModel, &notifier)
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(ourmiddleware.CorsMiddleware)
r.Route("/stacks", stackHandler.CreateRoutes)
r.Route("/auth", authHandler.CreateRoutes)
r.Route("/images", imageHandler.CreateRoutes)
r.Route("/notifications", func(r chi.Router) {
r.Use(ourmiddleware.GetUserIdFromUrl)
r.Get("/", CreateEventsHandler(&notifier))
})
logWriter := DatabaseWriter{
dbPool: db,
}
r.Route("/logs", createLogHandler(&logWriter))
return r
}

View File

@ -2,10 +2,13 @@ package stacks
import (
"database/sql"
"fmt"
"net/http"
"os"
. "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/middleware"
"screenmark/screenmark/models"
"strings"
"github.com/charmbracelet/log"
"github.com/go-chi/chi/v5"
@ -66,6 +69,29 @@ func (h *StackHandler) editStack(req EditStack, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNotImplemented)
}
func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, err := middleware.GetUserID(ctx, h.logger, w)
if err != nil {
return
}
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
if err != nil {
return
}
err = h.stackModel.Delete(ctx, listID, userID)
if err != nil {
h.logger.Warn("could not delete stack", "err", err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
type CreateStackBody struct {
Title string `json:"title"`
@ -80,9 +106,27 @@ func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter,
return
}
err = h.stackModel.SaveProcessing(ctx, userID, body.Title, body.Fields)
// Convert fields string to basic schema items
// For now, create a simple schema item for each field
var schemaItems []SchemaItems
if body.Fields != "" {
fields := strings.Split(body.Fields, ",")
for i, field := range fields {
field = strings.TrimSpace(field)
if field != "" {
schemaItems = append(schemaItems, SchemaItems{
Item: field,
Value: "",
Description: fmt.Sprintf("Field %d: %s", i+1, field),
})
}
}
}
// Use empty description for now since the API doesn't provide one
_, err = h.stackModel.Save(ctx, userID, body.Title, "", schemaItems)
if err != nil {
h.logger.Warn("could not save processing", "err", err)
h.logger.Warn("could not save stack", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
@ -102,6 +146,7 @@ func (h *StackHandler) CreateRoutes(r chi.Router) {
r.Post("/", middleware.WithValidatedPost(h.createStack))
r.Patch("/{listID}", middleware.WithValidatedPost(h.editStack))
r.Delete("/{listID}", h.deleteStack)
})
}