From 27ec40d7ce413c838c253c9fa184fc220e239f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gaw=C5=82owski?= Date: Sat, 9 May 2026 19:00:57 +0200 Subject: [PATCH 1/2] feat(db): add memory palace schema + backfill --- backend/internal/database/migrations.go | 3 + .../internal/database/migrations_objects.go | 96 +++++++++++++++++++ .../internal/database/migrations_palaces.go | 61 ++++++++++++ .../internal/database/schema_validation.go | 2 + 4 files changed, 162 insertions(+) create mode 100644 backend/internal/database/migrations_objects.go create mode 100644 backend/internal/database/migrations_palaces.go diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index e400b6a..8dd0104 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -14,6 +14,9 @@ func RunMigrations(db *sql.DB) error { migrations := []migration{ usersTableMigration(), usersGoogleOAuthMigration(), + memoryPalacesTableMigration(), + memoryPalaceObjectsTableMigration(), + backfillMemoryPalaceElementsToObjectsMigration(), } for _, m := range migrations { diff --git a/backend/internal/database/migrations_objects.go b/backend/internal/database/migrations_objects.go new file mode 100644 index 0000000..6679b16 --- /dev/null +++ b/backend/internal/database/migrations_objects.go @@ -0,0 +1,96 @@ +package database + +import ( + "database/sql" + "errors" +) + +func memoryPalaceObjectsTableMigration() migration { + return migration{ + name: "create_memory_palace_objects_table", + run: createMemoryPalaceObjectsTable, + } +} + +func backfillMemoryPalaceElementsToObjectsMigration() migration { + return migration{ + name: "backfill_memory_palace_elements_to_objects", + run: backfillMemoryPalaceElementsToObjects, + } +} + +func createMemoryPalaceObjectsTable(db *sql.DB) error { + const query = ` + CREATE TABLE IF NOT EXISTS memory_palace_objects ( + id BIGSERIAL PRIMARY KEY, + palace_id BIGINT NOT NULL REFERENCES memory_palaces(id) ON DELETE CASCADE, + type TEXT NOT NULL, + data JSONB NOT NULL DEFAULT '{}'::jsonb, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS memory_palace_objects_palace_id_idx + ON memory_palace_objects (palace_id); + + CREATE INDEX IF NOT EXISTS memory_palace_objects_palace_sort_idx + ON memory_palace_objects (palace_id, sort_order); + ` + + _, err := db.Exec(query) + return err +} + +func backfillMemoryPalaceElementsToObjects(db *sql.DB) error { + // One-time bridge migration: older builds stored the full elements list on + // memory_palaces.elements. Newer builds store each element as a row in + // memory_palace_objects. To avoid losing data, copy elements into objects for + // any palace that has elements but no objects yet. + const query = ` + INSERT INTO memory_palace_objects (palace_id, type, data, sort_order) + SELECT + p.id, + COALESCE(elem ->> 'type', 'unknown') AS type, + elem AS data, + (ordinality - 1)::integer AS sort_order + FROM memory_palaces p + CROSS JOIN LATERAL jsonb_array_elements(p.elements) WITH ORDINALITY AS t(elem, ordinality) + WHERE jsonb_typeof(p.elements) = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM memory_palace_objects o + WHERE o.palace_id = p.id + ); + ` + + _, err := db.Exec(query) + return err +} + +func memoryPalaceObjectsSchemaCheck() schemaCheck { + return schemaCheck{ + name: "memory_palace_objects_table_exists", + validate: ensureMemoryPalaceObjectsTableExists, + } +} + +func ensureMemoryPalaceObjectsTableExists(db *sql.DB) error { + const query = ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'memory_palace_objects' + ); + ` + + var exists bool + if err := db.QueryRow(query).Scan(&exists); err != nil { + return err + } + if !exists { + return errors.New("required table 'memory_palace_objects' does not exist; run migrations with: go run ./cmd/migrate") + } + + return nil +} diff --git a/backend/internal/database/migrations_palaces.go b/backend/internal/database/migrations_palaces.go new file mode 100644 index 0000000..9aa3d19 --- /dev/null +++ b/backend/internal/database/migrations_palaces.go @@ -0,0 +1,61 @@ +package database + +import ( + "database/sql" + "errors" +) + +func memoryPalacesTableMigration() migration { + return migration{ + name: "create_memory_palaces_table", + run: createMemoryPalacesTable, + } +} + +func createMemoryPalacesTable(db *sql.DB) error { + const query = ` + CREATE TABLE IF NOT EXISTS memory_palaces ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'public', 'unlisted')), + elements JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS memory_palaces_user_id_idx + ON memory_palaces (user_id); + ` + + _, err := db.Exec(query) + return err +} + +func memoryPalacesSchemaCheck() schemaCheck { + return schemaCheck{ + name: "memory_palaces_table_exists", + validate: ensureMemoryPalacesTableExists, + } +} + +func ensureMemoryPalacesTableExists(db *sql.DB) error { + const query = ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'memory_palaces' + ); + ` + + var exists bool + if err := db.QueryRow(query).Scan(&exists); err != nil { + return err + } + if !exists { + return errors.New("required table 'memory_palaces' does not exist; run migrations with: go run ./cmd/migrate") + } + + return nil +} diff --git a/backend/internal/database/schema_validation.go b/backend/internal/database/schema_validation.go index 30a0b05..fe50f37 100644 --- a/backend/internal/database/schema_validation.go +++ b/backend/internal/database/schema_validation.go @@ -14,6 +14,8 @@ func ValidateRequiredSchema(db *sql.DB) error { checks := []schemaCheck{ usersSchemaCheck(), usersGoogleOAuthSchemaCheck(), + memoryPalacesSchemaCheck(), + memoryPalaceObjectsSchemaCheck(), } for _, c := range checks { From 46cc61d85f159fc606c025c6388039410bd0c4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gaw=C5=82owski?= Date: Sat, 9 May 2026 19:01:18 +0200 Subject: [PATCH 2/2] feat(backend): add JWT-protected palaces and objects API --- backend/auth_middleware.go | 63 +++++ backend/main.go | 11 + backend/palace_objects.go | 526 ++++++++++++++++++++++++++++++++++++ backend/palaces.go | 529 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1129 insertions(+) create mode 100644 backend/auth_middleware.go create mode 100644 backend/palace_objects.go create mode 100644 backend/palaces.go diff --git a/backend/auth_middleware.go b/backend/auth_middleware.go new file mode 100644 index 0000000..205ee5c --- /dev/null +++ b/backend/auth_middleware.go @@ -0,0 +1,63 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +const ( + userIDContextKey = "user_id" + userEmailContextKey = "user_email" +) + +func authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authorization := strings.TrimSpace(c.GetHeader("Authorization")) + if authorization == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + return + } + + parts := strings.SplitN(authorization, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + return + } + + claims := &jwtClaims{} + token, err := jwt.ParseWithClaims( + parts[1], + claims, + func(_ *jwt.Token) (any, error) { + return []byte(jwtSecret()), nil + }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + ) + if err != nil || token == nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + if claims.UserID <= 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"}) + return + } + + c.Set(userIDContextKey, claims.UserID) + c.Set(userEmailContextKey, claims.Email) + c.Next() + } +} + +func currentUserID(c *gin.Context) (int64, bool) { + val, ok := c.Get(userIDContextKey) + if !ok { + return 0, false + } + + userID, ok := val.(int64) + return userID, ok && userID > 0 +} diff --git a/backend/main.go b/backend/main.go index 45a7956..7dbafaa 100644 --- a/backend/main.go +++ b/backend/main.go @@ -567,6 +567,17 @@ func main() { }) }) + auth := r.Group("/", authMiddleware()) + auth.GET("/palaces", listPalacesHandler(db)) + auth.POST("/palaces", createPalaceHandler(db)) + auth.GET("/palaces/:id", getPalaceHandler(db)) + auth.PUT("/palaces/:id", updatePalaceHandler(db)) + auth.DELETE("/palaces/:id", deletePalaceHandler(db)) + auth.GET("/palaces/:id/objects", listPalaceObjectsHandler(db)) + auth.POST("/palaces/:id/objects", createPalaceObjectHandler(db)) + auth.PUT("/objects/:objectId", updateObjectHandler(db)) + auth.DELETE("/objects/:objectId", deleteObjectHandler(db)) + port := os.Getenv("PORT") if port == "" { port = "8080" diff --git a/backend/palace_objects.go b/backend/palace_objects.go new file mode 100644 index 0000000..4f310ea --- /dev/null +++ b/backend/palace_objects.go @@ -0,0 +1,526 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +func parseObjectID(c *gin.Context) (int64, bool) { + idStr := strings.TrimSpace(c.Param("objectId")) + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid object id"}) + return 0, false + } + return id, true +} + +func normalizeObjectType(value string) (string, error) { + t := strings.TrimSpace(value) + if t == "" { + return "", errors.New("type is required") + } + return t, nil +} + +func palaceExistsForUser(ctx *gin.Context, db *sql.DB, userID, palaceID int64) (bool, error) { + const query = `SELECT EXISTS (SELECT 1 FROM memory_palaces WHERE id = $1 AND user_id = $2);` + var exists bool + err := db.QueryRowContext(ctx.Request.Context(), query, palaceID, userID).Scan(&exists) + return exists, err +} + +func decodeBodyObject(c *gin.Context) (map[string]any, error) { + raw, err := c.GetRawData() + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errors.New("empty request body") + } + if !json.Valid(raw) { + return nil, errors.New("invalid JSON") + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, errors.New("invalid JSON") + } + + obj, ok := value.(map[string]any) + if !ok { + return nil, errors.New("request body must be a JSON object") + } + + return obj, nil +} + +func parseOptionalInt(value any) (*int, error) { + if value == nil { + return nil, nil + } + + switch v := value.(type) { + case float64: + asInt := int(v) + if float64(asInt) != v { + return nil, errors.New("sort_order must be an integer") + } + return &asInt, nil + case int: + vv := v + return &vv, nil + case int64: + vv := int(v) + if int64(vv) != v { + return nil, errors.New("sort_order is out of range") + } + return &vv, nil + case json.Number: + i64, err := v.Int64() + if err != nil { + return nil, errors.New("sort_order must be an integer") + } + vv := int(i64) + if int64(vv) != i64 { + return nil, errors.New("sort_order is out of range") + } + return &vv, nil + default: + return nil, errors.New("sort_order must be an integer") + } +} + +func copyMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)) + for k, v := range input { + out[k] = v + } + return out +} + +func isLegacyShape(payload map[string]any) bool { + _, hasData := payload["data"] + if !hasData { + return false + } + + for key := range payload { + switch key { + case "type", "data", "sort_order": + continue + default: + return false + } + } + + return true +} + +func extractElementForCreate(payload map[string]any) (typeVal string, dataJSON []byte, sortOrder *int, err error) { + if rawSort, ok := payload["sort_order"]; ok { + sortOrder, err = parseOptionalInt(rawSort) + if err != nil { + return "", nil, nil, err + } + } + + var element map[string]any + if isLegacyShape(payload) { + rawData, ok := payload["data"].(map[string]any) + if !ok { + return "", nil, nil, errors.New("data must be a JSON object") + } + element = copyMap(rawData) + + if t, ok := payload["type"].(string); ok { + element["type"] = t + } + } else { + element = copyMap(payload) + delete(element, "sort_order") + } + + delete(element, "id") + + rawType, ok := element["type"].(string) + if !ok { + return "", nil, nil, errors.New("type is required") + } + + typeVal, err = normalizeObjectType(rawType) + if err != nil { + return "", nil, nil, err + } + element["type"] = typeVal + + dataJSON, err = json.Marshal(element) + if err != nil { + return "", nil, nil, err + } + + return typeVal, dataJSON, sortOrder, nil +} + +func extractElementPatch(payload map[string]any) (patch map[string]any, sortOrder *int, err error) { + if rawSort, ok := payload["sort_order"]; ok { + sortOrder, err = parseOptionalInt(rawSort) + if err != nil { + return nil, nil, err + } + } + + if isLegacyShape(payload) { + rawData, ok := payload["data"].(map[string]any) + if !ok { + return nil, nil, errors.New("data must be a JSON object") + } + patch = copyMap(rawData) + if t, ok := payload["type"].(string); ok { + patch["type"] = t + } + return patch, sortOrder, nil + } + + patch = copyMap(payload) + delete(patch, "sort_order") + delete(patch, "id") + return patch, sortOrder, nil +} + +func buildFlattenedObject(id int64, sortOrder int, typeVal string, data []byte) map[string]any { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil || obj == nil { + obj = map[string]any{} + } + obj["type"] = typeVal + obj["id"] = id + obj["sort_order"] = sortOrder + return obj +} + +func listPalaceObjectsHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + palaceID, ok := parsePalaceID(c) + if !ok { + return + } + + exists, err := palaceExistsForUser(c, db, userID, palaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list objects"}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "palace not found"}) + return + } + + rows, err := db.QueryContext( + c.Request.Context(), + `SELECT o.id, o.type, o.data, o.sort_order + FROM memory_palace_objects o + WHERE o.palace_id = $1 + ORDER BY o.sort_order ASC, o.updated_at DESC`, + palaceID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list objects"}) + return + } + defer rows.Close() + + objects := make([]map[string]any, 0) + for rows.Next() { + var id int64 + var typeVal string + var dataOut []byte + var sortOrder int + if err := rows.Scan(&id, &typeVal, &dataOut, &sortOrder); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list objects"}) + return + } + + objects = append(objects, buildFlattenedObject(id, sortOrder, typeVal, dataOut)) + } + if err := rows.Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list objects"}) + return + } + + c.JSON(http.StatusOK, gin.H{"objects": objects}) + } +} + +func createPalaceObjectHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + palaceID, ok := parsePalaceID(c) + if !ok { + return + } + + payload, err := decodeBodyObject(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + typeVal, dataJSON, sortOrder, err := extractElementForCreate(payload) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tx, err := db.BeginTx(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create object"}) + return + } + defer func() { _ = tx.Rollback() }() + + query := ` + INSERT INTO memory_palace_objects (palace_id, type, data, sort_order) + SELECT p.id, $2, $3::jsonb, + COALESCE($4::integer, COALESCE((SELECT MAX(o.sort_order) + 1 FROM memory_palace_objects o WHERE o.palace_id = p.id), 0)) + FROM memory_palaces p + WHERE p.id = $1 AND p.user_id = $5 + RETURNING id, palace_id, type, data, sort_order + ` + + sortOrderParam := any(nil) + if sortOrder != nil { + sortOrderParam = *sortOrder + } + + var id int64 + var palaceIDOut int64 + var typeOut string + var dataOut []byte + var sortOrderOut int + err = tx.QueryRowContext( + c.Request.Context(), + query, + palaceID, + typeVal, + string(dataJSON), + sortOrderParam, + userID, + ).Scan(&id, &palaceIDOut, &typeOut, &dataOut, &sortOrderOut) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "palace not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create object"}) + return + } + + if _, err := tx.ExecContext(c.Request.Context(), `UPDATE memory_palaces SET updated_at = NOW() WHERE id = $1 AND user_id = $2`, palaceIDOut, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create object"}) + return + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create object"}) + return + } + + c.JSON(http.StatusCreated, buildFlattenedObject(id, sortOrderOut, typeOut, dataOut)) + } +} + +func updateObjectHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + objectID, ok := parseObjectID(c) + if !ok { + return + } + + payload, err := decodeBodyObject(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch, sortOrder, err := extractElementPatch(payload) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(patch) == 0 && sortOrder == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + tx, err := db.BeginTx(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + defer func() { _ = tx.Rollback() }() + + var palaceID int64 + var currentType string + var currentData []byte + var currentSortOrder int + selectQuery := ` + SELECT o.palace_id, o.type, o.data, o.sort_order + FROM memory_palace_objects o + JOIN memory_palaces p ON p.id = o.palace_id + WHERE o.id = $1 AND p.user_id = $2 + ` + if err := tx.QueryRowContext(c.Request.Context(), selectQuery, objectID, userID).Scan(&palaceID, ¤tType, ¤tData, ¤tSortOrder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "object not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + + var element map[string]any + if err := json.Unmarshal(currentData, &element); err != nil || element == nil { + element = map[string]any{} + } + + for k, v := range patch { + element[k] = v + } + + newType := currentType + if rawType, ok := element["type"].(string); ok { + normalized, err := normalizeObjectType(rawType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + newType = normalized + } + element["type"] = newType + delete(element, "id") + delete(element, "sort_order") + + dataJSON, err := json.Marshal(element) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + + newSortOrder := currentSortOrder + if sortOrder != nil { + newSortOrder = *sortOrder + } + + updateQuery := ` + UPDATE memory_palace_objects o + SET type = $3, + data = $4::jsonb, + sort_order = $5, + updated_at = NOW() + FROM memory_palaces p + WHERE o.palace_id = p.id + AND o.id = $1 + AND p.user_id = $2 + RETURNING o.id, o.type, o.data, o.sort_order + ` + + var idOut int64 + var typeOut string + var dataOut []byte + var sortOrderOut int + if err := tx.QueryRowContext(c.Request.Context(), updateQuery, objectID, userID, newType, string(dataJSON), newSortOrder).Scan(&idOut, &typeOut, &dataOut, &sortOrderOut); err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "object not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + + if _, err := tx.ExecContext(c.Request.Context(), `UPDATE memory_palaces SET updated_at = NOW() WHERE id = $1 AND user_id = $2`, palaceID, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update object"}) + return + } + + c.JSON(http.StatusOK, buildFlattenedObject(idOut, sortOrderOut, typeOut, dataOut)) + } +} + +func deleteObjectHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + objectID, ok := parseObjectID(c) + if !ok { + return + } + + tx, err := db.BeginTx(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + return + } + defer func() { _ = tx.Rollback() }() + + var palaceID int64 + deleteQuery := ` + DELETE FROM memory_palace_objects o + USING memory_palaces p + WHERE o.palace_id = p.id + AND o.id = $1 + AND p.user_id = $2 + RETURNING o.palace_id + ` + err = tx.QueryRowContext(c.Request.Context(), deleteQuery, objectID, userID).Scan(&palaceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "object not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + return + } + + if _, err := tx.ExecContext(c.Request.Context(), `UPDATE memory_palaces SET updated_at = NOW() WHERE id = $1 AND user_id = $2`, palaceID, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + return + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + return + } + + c.Status(http.StatusNoContent) + } +} diff --git a/backend/palaces.go b/backend/palaces.go new file mode 100644 index 0000000..c573683 --- /dev/null +++ b/backend/palaces.go @@ -0,0 +1,529 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type palaceCreateRequest struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Visibility string `json:"visibility"` + Elements json.RawMessage `json:"elements"` +} + +type palaceUpdateRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + Visibility *string `json:"visibility"` + Elements *json.RawMessage `json:"elements"` +} + +type palaceSummaryResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description"` + Visibility string `json:"visibility"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type palaceDetailResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description"` + Visibility string `json:"visibility"` + Elements json.RawMessage `json:"elements"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +var errPalaceNotFound = errors.New("palace not found") + +func normalizeVisibility(value string) (string, error) { + v := strings.ToLower(strings.TrimSpace(value)) + if v == "" { + return "private", nil + } + + switch v { + case "private", "public", "unlisted": + return v, nil + default: + return "", errors.New("invalid visibility") + } +} + +func normalizePalaceElements(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return json.RawMessage("[]"), nil + } + if !json.Valid(raw) { + return nil, errors.New("elements must be valid JSON") + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, errors.New("elements must be a JSON array") + } + if _, ok := value.([]any); !ok { + return nil, errors.New("elements must be a JSON array") + } + + return raw, nil +} + +func decodePalaceElements(raw json.RawMessage) ([]map[string]any, error) { + if len(raw) == 0 { + return []map[string]any{}, nil + } + if !json.Valid(raw) { + return nil, errors.New("elements must be valid JSON") + } + + var arr []any + if err := json.Unmarshal(raw, &arr); err != nil { + return nil, errors.New("elements must be a JSON array") + } + + elements := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + obj, ok := item.(map[string]any) + if !ok { + return nil, errors.New("elements must be a JSON array of objects") + } + + typeVal, ok := obj["type"].(string) + if !ok || strings.TrimSpace(typeVal) == "" { + return nil, errors.New("each element must include a non-empty 'type' field") + } + + obj["type"] = strings.TrimSpace(typeVal) + delete(obj, "id") + delete(obj, "sort_order") + elements = append(elements, obj) + } + + return elements, nil +} + +func replacePalaceObjects(ctx context.Context, tx *sql.Tx, palaceID int64, elements []map[string]any) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM memory_palace_objects WHERE palace_id = $1`, palaceID); err != nil { + return err + } + if len(elements) == 0 { + return nil + } + + const insertQuery = `INSERT INTO memory_palace_objects (palace_id, type, data, sort_order) VALUES ($1, $2, $3::jsonb, $4);` + for idx, element := range elements { + typeVal, _ := element["type"].(string) + typeVal = strings.TrimSpace(typeVal) + if typeVal == "" { + return errors.New("each element must include a non-empty 'type' field") + } + element["type"] = typeVal + delete(element, "id") + delete(element, "sort_order") + + payload, err := json.Marshal(element) + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, insertQuery, palaceID, typeVal, string(payload), idx); err != nil { + return err + } + } + + return nil +} + +func parsePalaceID(c *gin.Context) (int64, bool) { + idStr := strings.TrimSpace(c.Param("id")) + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid palace id"}) + return 0, false + } + return id, true +} + +func listPalacesHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + rows, err := db.QueryContext( + c.Request.Context(), + `SELECT id, title, description, visibility, created_at, updated_at + FROM memory_palaces + WHERE user_id = $1 + ORDER BY updated_at DESC`, + userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list palaces"}) + return + } + defer rows.Close() + + palaces := make([]palaceSummaryResponse, 0) + for rows.Next() { + var palace palaceSummaryResponse + var description sql.NullString + if err := rows.Scan(&palace.ID, &palace.Title, &description, &palace.Visibility, &palace.CreatedAt, &palace.UpdatedAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list palaces"}) + return + } + if description.Valid { + palace.Description = &description.String + } + palaces = append(palaces, palace) + } + if err := rows.Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list palaces"}) + return + } + + c.JSON(http.StatusOK, gin.H{"palaces": palaces}) + } +} + +func createPalaceHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var req palaceCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + + visibility, err := normalizeVisibility(req.Visibility) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid visibility"}) + return + } + + elements, err := decodePalaceElements(req.Elements) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var description any + if strings.TrimSpace(req.Description) != "" { + description = req.Description + } + + tx, err := db.BeginTx(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create palace"}) + return + } + defer func() { + _ = tx.Rollback() + }() + + var palaceID int64 + insertQuery := ` + INSERT INTO memory_palaces (user_id, title, description, visibility) + VALUES ($1, $2, $3, $4) + RETURNING id + ` + if err := tx.QueryRowContext(c.Request.Context(), insertQuery, userID, title, description, visibility).Scan(&palaceID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create palace"}) + return + } + + if err := replacePalaceObjects(c.Request.Context(), tx, palaceID, elements); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create palace"}) + return + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create palace"}) + return + } + + resp, err := fetchPalaceDetail(c, db, userID, palaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create palace"}) + return + } + + c.JSON(http.StatusCreated, resp) + } +} + +func getPalaceHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + palaceID, ok := parsePalaceID(c) + if !ok { + return + } + + resp, err := fetchPalaceDetail(c, db, userID, palaceID) + if err != nil { + if errors.Is(err, errPalaceNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "palace not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch palace"}) + return + } + + c.JSON(http.StatusOK, resp) + } +} + +func updatePalaceHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + palaceID, ok := parsePalaceID(c) + if !ok { + return + } + + var req palaceUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + var title any + if req.Title != nil { + trimmed := strings.TrimSpace(*req.Title) + if trimmed == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "title cannot be empty"}) + return + } + title = trimmed + } + + var description any + if req.Description != nil { + description = *req.Description + } + + var visibility any + if req.Visibility != nil { + normalized, err := normalizeVisibility(*req.Visibility) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid visibility"}) + return + } + visibility = normalized + } + + var elements []map[string]any + hasElements := false + if req.Elements != nil { + hasElements = true + decoded, err := decodePalaceElements(*req.Elements) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + elements = decoded + } + + if req.Title == nil && req.Description == nil && req.Visibility == nil && !hasElements { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + tx, err := db.BeginTx(c.Request.Context(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update palace"}) + return + } + defer func() { + _ = tx.Rollback() + }() + + updateQuery := ` + UPDATE memory_palaces + SET + title = COALESCE($1, title), + description = COALESCE($2, description), + visibility = COALESCE($3, visibility), + updated_at = NOW() + WHERE id = $4 AND user_id = $5 + RETURNING id + ` + + var updatedID int64 + if err := tx.QueryRowContext(c.Request.Context(), updateQuery, title, description, visibility, palaceID, userID).Scan(&updatedID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "palace not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update palace"}) + return + } + + if hasElements { + if err := replacePalaceObjects(c.Request.Context(), tx, palaceID, elements); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update palace"}) + return + } + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update palace"}) + return + } + + resp, err := fetchPalaceDetail(c, db, userID, palaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update palace"}) + return + } + + c.JSON(http.StatusOK, resp) + } +} + +func deletePalaceHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + palaceID, ok := parsePalaceID(c) + if !ok { + return + } + + res, err := db.ExecContext( + c.Request.Context(), + `DELETE FROM memory_palaces WHERE id = $1 AND user_id = $2`, + palaceID, + userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete palace"}) + return + } + affected, err := res.RowsAffected() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete palace"}) + return + } + if affected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "palace not found"}) + return + } + + c.Status(http.StatusNoContent) + } +} + +func fetchPalaceDetail(c *gin.Context, db *sql.DB, userID, palaceID int64) (palaceDetailResponse, error) { + var resp palaceDetailResponse + var descriptionOut sql.NullString + + query := ` + SELECT id, title, description, visibility, created_at, updated_at + FROM memory_palaces + WHERE id = $1 AND user_id = $2 + ` + + err := db.QueryRowContext(c.Request.Context(), query, palaceID, userID).Scan( + &resp.ID, + &resp.Title, + &descriptionOut, + &resp.Visibility, + &resp.CreatedAt, + &resp.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return palaceDetailResponse{}, errPalaceNotFound + } + return palaceDetailResponse{}, err + } + + if descriptionOut.Valid { + resp.Description = &descriptionOut.String + } + + rows, err := db.QueryContext( + c.Request.Context(), + `SELECT type, data + FROM memory_palace_objects + WHERE palace_id = $1 + ORDER BY sort_order ASC, updated_at DESC`, + palaceID, + ) + if err != nil { + return palaceDetailResponse{}, err + } + defer rows.Close() + + elements := make([]json.RawMessage, 0) + for rows.Next() { + var typeVal string + var dataOut []byte + if err := rows.Scan(&typeVal, &dataOut); err != nil { + return palaceDetailResponse{}, err + } + + var obj map[string]any + if err := json.Unmarshal(dataOut, &obj); err != nil || obj == nil { + obj = map[string]any{} + } + obj["type"] = typeVal + delete(obj, "id") + delete(obj, "sort_order") + payload, err := json.Marshal(obj) + if err != nil { + return palaceDetailResponse{}, err + } + elements = append(elements, json.RawMessage(payload)) + } + if err := rows.Err(); err != nil { + return palaceDetailResponse{}, err + } + + payload, err := json.Marshal(elements) + if err != nil { + return palaceDetailResponse{}, err + } + resp.Elements = json.RawMessage(payload) + + return resp, nil +}