diff --git a/go-backend/handlers/entity.go b/go-backend/handlers/entity.go index ce58d3f..b7277a1 100644 --- a/go-backend/handlers/entity.go +++ b/go-backend/handlers/entity.go @@ -20,6 +20,7 @@ type UpdateEntityRequest struct { Name string `json:"name"` Description string `json:"description"` Type string `json:"type"` + CardPK *int `json:"card_pk"` } func (s *Handler) ExtractSaveCardEntities(userID int, card models.Card) error { @@ -65,10 +66,10 @@ func (s *Handler) UpsertEntities(userID int, cardPK int, entities []models.Entit if err == sql.ErrNoRows { // Entity doesn't exist, insert it err = s.DB.QueryRow(` - INSERT INTO entities (user_id, name, description, type, embedding) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO entities (user_id, name, description, type, embedding, card_pk) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - `, userID, entity.Name, entity.Description, entity.Type, entity.Embedding).Scan(&entityID) + `, userID, entity.Name, entity.Description, entity.Type, entity.Embedding, entity.CardPK).Scan(&entityID) if err != nil { log.Printf("error inserting entity: %v", err) continue @@ -82,9 +83,10 @@ func (s *Handler) UpsertEntities(userID int, cardPK int, entities []models.Entit UPDATE entities SET description = $1, type = $2, + card_pk = $3, updated_at = NOW() - WHERE id = $3 - `, entity.Description, entity.Type, entityID) + WHERE id = $4 + `, entity.Description, entity.Type, entity.CardPK, entityID) if err != nil { log.Printf("error updating entity: %v", err) continue @@ -146,15 +148,24 @@ func (s *Handler) GetEntitiesRoute(w http.ResponseWriter, r *http.Request) { e.type, e.created_at, e.updated_at, - COUNT(DISTINCT ecj.card_pk) as card_count + e.card_pk, + COUNT(DISTINCT ecj.card_pk) as card_count, + c.id as linked_card_id, + c.card_id as linked_card_card_id, + c.title as linked_card_title, + c.user_id as linked_card_user_id, + c.parent_id as linked_card_parent_id, + c.created_at as linked_card_created_at, + c.updated_at as linked_card_updated_at FROM entities e LEFT JOIN entity_card_junction ecj ON e.id = ecj.entity_id - LEFT JOIN cards c ON ecj.card_pk = c.id AND c.is_deleted = FALSE + LEFT JOIN cards c ON e.card_pk = c.id AND c.is_deleted = FALSE WHERE e.user_id = $1 GROUP BY - e.id, e.user_id, e.name, e.description, e.type, e.created_at, e.updated_at + e.id, e.user_id, e.name, e.description, e.type, e.created_at, e.updated_at, e.card_pk, + c.id, c.card_id, c.title, c.user_id, c.parent_id, c.created_at, c.updated_at ORDER BY e.name ASC ` @@ -170,6 +181,11 @@ func (s *Handler) GetEntitiesRoute(w http.ResponseWriter, r *http.Request) { var entities []models.Entity for rows.Next() { var entity models.Entity + var cardID sql.NullInt64 + var cardCardID, cardTitle sql.NullString + var cardUserID, cardParentID sql.NullInt64 + var cardCreatedAt, cardUpdatedAt sql.NullTime + err := rows.Scan( &entity.ID, &entity.UserID, @@ -178,13 +194,36 @@ func (s *Handler) GetEntitiesRoute(w http.ResponseWriter, r *http.Request) { &entity.Type, &entity.CreatedAt, &entity.UpdatedAt, + &entity.CardPK, &entity.CardCount, + &cardID, + &cardCardID, + &cardTitle, + &cardUserID, + &cardParentID, + &cardCreatedAt, + &cardUpdatedAt, ) if err != nil { log.Printf("error scanning entity: %v", err) http.Error(w, "Failed to scan entities", http.StatusInternalServerError) return } + + // If we have a linked card, populate the card field + if cardID.Valid { + entity.Card = &models.PartialCard{ + ID: int(cardID.Int64), + CardID: cardCardID.String, + Title: cardTitle.String, + UserID: int(cardUserID.Int64), + ParentID: int(cardParentID.Int64), + CreatedAt: cardCreatedAt.Time, + UpdatedAt: cardUpdatedAt.Time, + Tags: []models.Tag{}, // Empty tags array since we don't need them here + } + } + entities = append(entities, entity) } @@ -194,20 +233,22 @@ func (s *Handler) GetEntitiesRoute(w http.ResponseWriter, r *http.Request) { func (s *Handler) QueryEntitiesForCard(userID int, cardPK int) ([]models.Entity, error) { query := ` - SELECT - e.id, e.user_id, e.name, e.description, e.type, e.created_at, e.updated_at + SELECT DISTINCT + e.id, e.user_id, e.name, e.description, e.type, e.created_at, e.updated_at, e.card_pk FROM entities e - JOIN + LEFT JOIN entity_card_junction ecj ON e.id = ecj.entity_id WHERE - ecj.card_pk = $1 AND e.user_id = $2` + e.user_id = $2 + AND (ecj.card_pk = $1 OR e.card_pk = $1)` rows, err := s.DB.Query(query, cardPK, userID) if err != nil { log.Printf("err %v", err) return []models.Entity{}, err } + defer rows.Close() var entities []models.Entity for rows.Next() { @@ -220,6 +261,7 @@ func (s *Handler) QueryEntitiesForCard(userID int, cardPK int) ([]models.Entity, &entity.Type, &entity.CreatedAt, &entity.UpdatedAt, + &entity.CardPK, ); err != nil { log.Printf("err %v", err) return entities, err @@ -411,6 +453,26 @@ func (s *Handler) DeleteEntityRoute(w http.ResponseWriter, r *http.Request) { }) } +func (s *Handler) validateCardAccess(userID int, cardPK int) error { + var exists bool + err := s.DB.QueryRow(` + SELECT EXISTS( + SELECT 1 FROM cards + WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE + ) + `, cardPK, userID).Scan(&exists) + + if err != nil { + return fmt.Errorf("error checking card access: %w", err) + } + + if !exists { + return fmt.Errorf("card not found or access denied") + } + + return nil +} + func (s *Handler) UpdateEntity(userID int, entityID int, params UpdateEntityRequest) error { // Start transaction tx, err := s.DB.Begin() @@ -435,6 +497,13 @@ func (s *Handler) UpdateEntity(userID int, entityID int, params UpdateEntityRequ return fmt.Errorf("entity not found or does not belong to user") } + // Validate card access if CardPK is provided + if params.CardPK != nil { + if err := s.validateCardAccess(userID, *params.CardPK); err != nil { + return fmt.Errorf("invalid card reference: %w", err) + } + } + // Check if name is unique for this user var nameExists bool err = tx.QueryRow(` @@ -470,9 +539,10 @@ func (s *Handler) UpdateEntity(userID int, entityID int, params UpdateEntityRequ SET name = $1, description = $2, type = $3, + card_pk = $4, updated_at = NOW() - WHERE id = $4 AND user_id = $5` - queryArgs = []interface{}{params.Name, params.Description, params.Type, entityID, userID} + WHERE id = $5 AND user_id = $6` + queryArgs = []interface{}{params.Name, params.Description, params.Type, params.CardPK, entityID, userID} } else { // In normal mode, update with new embedding embedding, err := llms.GenerateEntityEmbedding(s.Server.LLMClient, entity) @@ -484,10 +554,11 @@ func (s *Handler) UpdateEntity(userID int, entityID int, params UpdateEntityRequ SET name = $1, description = $2, type = $3, - embedding = $4, + card_pk = $4, + embedding = $5, updated_at = NOW() - WHERE id = $5 AND user_id = $6` - queryArgs = []interface{}{params.Name, params.Description, params.Type, embedding, entityID, userID} + WHERE id = $6 AND user_id = $7` + queryArgs = []interface{}{params.Name, params.Description, params.Type, params.CardPK, embedding, entityID, userID} } // Update the entity diff --git a/go-backend/handlers/entity_test.go b/go-backend/handlers/entity_test.go index 4c1e359..7be9e90 100644 --- a/go-backend/handlers/entity_test.go +++ b/go-backend/handlers/entity_test.go @@ -1,6 +1,7 @@ package handlers import ( + "go-backend/models" "go-backend/tests" "strings" "testing" @@ -215,3 +216,75 @@ func TestUpdateEntityNonExistent(t *testing.T) { t.Error("Expected error when updating non-existent entity") } } + +func TestUpdateEntityWithCardPK(t *testing.T) { + s := setup() + defer tests.Teardown() + + // Create a test card + var cardID int + err := s.DB.QueryRow(` + INSERT INTO cards (user_id, title, body) + VALUES ($1, 'Test Card', 'Test Content') + RETURNING id + `, 1).Scan(&cardID) + if err != nil { + t.Fatalf("Failed to create test card: %v", err) + } + + // Update entity with card_pk + params := UpdateEntityRequest{ + Name: "Updated Entity", + Description: "Updated Description", + Type: "person", + CardPK: &cardID, + } + + err = s.UpdateEntity(1, 1, params) + if err != nil { + t.Errorf("UpdateEntity failed: %v", err) + } + + // Verify the update + var entity models.Entity + err = s.DB.QueryRow(` + SELECT id, user_id, name, description, type, card_pk + FROM entities + WHERE id = $1 + `, 1).Scan( + &entity.ID, + &entity.UserID, + &entity.Name, + &entity.Description, + &entity.Type, + &entity.CardPK, + ) + if err != nil { + t.Errorf("Failed to verify entity update: %v", err) + } + if *entity.CardPK != cardID { + t.Errorf("Expected card_pk to be %d, got %d", cardID, *entity.CardPK) + } +} + +func TestUpdateEntityWithInvalidCardPK(t *testing.T) { + s := setup() + defer tests.Teardown() + + // Try to update with non-existent card + invalidCardID := 99999 + params := UpdateEntityRequest{ + Name: "Updated Entity", + Description: "Updated Description", + Type: "person", + CardPK: &invalidCardID, + } + + err := s.UpdateEntity(1, 1, params) + if err == nil { + t.Error("Expected error when updating with invalid card_pk") + } + if !strings.Contains(err.Error(), "card not found") { + t.Errorf("Expected 'card not found' error, got: %v", err) + } +} diff --git a/go-backend/models/entity.go b/go-backend/models/entity.go index 700593c..36b3f31 100644 --- a/go-backend/models/entity.go +++ b/go-backend/models/entity.go @@ -16,6 +16,8 @@ type Entity struct { UpdatedAt time.Time `json:"updated_at"` Embedding pgvector.Vector `json:"embedding"` CardCount int `json:"card_count"` + CardPK *int `json:"card_pk"` + Card *PartialCard `json:"card,omitempty"` } type EntityCardJunction struct { diff --git a/go-backend/schema/0035-add-card-pk-to-entities.sql b/go-backend/schema/0035-add-card-pk-to-entities.sql new file mode 100644 index 0000000..5d8aa08 --- /dev/null +++ b/go-backend/schema/0035-add-card-pk-to-entities.sql @@ -0,0 +1,3 @@ +ALTER TABLE entities +ADD COLUMN card_pk INTEGER, +ADD CONSTRAINT fk_card_pk FOREIGN KEY (card_pk) REFERENCES cards(id); \ No newline at end of file diff --git a/zettelkasten-front/src/api/entities.ts b/zettelkasten-front/src/api/entities.ts index 470ae61..0a0368d 100644 --- a/zettelkasten-front/src/api/entities.ts +++ b/zettelkasten-front/src/api/entities.ts @@ -76,6 +76,7 @@ export interface UpdateEntityRequest { name: string; description: string; type: string; + card_pk: number | null; } export function updateEntity(entityId: number, data: UpdateEntityRequest): Promise { diff --git a/zettelkasten-front/src/components/entities/EditEntityDialog.tsx b/zettelkasten-front/src/components/entities/EditEntityDialog.tsx index 27fb77b..42da03c 100644 --- a/zettelkasten-front/src/components/entities/EditEntityDialog.tsx +++ b/zettelkasten-front/src/components/entities/EditEntityDialog.tsx @@ -1,7 +1,9 @@ import React, { useState, useEffect } from "react"; import { Dialog } from "@headlessui/react"; -import { Entity } from "../../models/Card"; +import { Entity, PartialCard } from "../../models/Card"; import { UpdateEntityRequest, updateEntity } from "../../api/entities"; +import { BacklinkInput } from "../cards/BacklinkInput"; +import { CardTag } from "../cards/CardTag"; interface EditEntityDialogProps { entity: Entity | null; @@ -14,6 +16,8 @@ export function EditEntityDialog({ entity, isOpen, onClose, onSuccess }: EditEnt const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [type, setType] = useState(""); + const [linkedCard, setLinkedCard] = useState(null); + const [showCardLink, setShowCardLink] = useState(false); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -22,9 +26,19 @@ export function EditEntityDialog({ entity, isOpen, onClose, onSuccess }: EditEnt setName(entity.name); setDescription(entity.description); setType(entity.type); + setLinkedCard(entity.card || null); } }, [entity]); + const handleBacklink = (card: PartialCard) => { + setLinkedCard(card); + setShowCardLink(false); + }; + + const handleRemoveCard = () => { + setLinkedCard(null); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!entity) return; @@ -37,6 +51,7 @@ export function EditEntityDialog({ entity, isOpen, onClose, onSuccess }: EditEnt name: name.trim(), description: description.trim(), type: type.trim(), + card_pk: linkedCard?.id || null, }; await updateEntity(entity.id, data); @@ -104,6 +119,40 @@ export function EditEntityDialog({ entity, isOpen, onClose, onSuccess }: EditEnt /> +
+ + {linkedCard ? ( +
+ + +
+ ) : ( +
+ {showCardLink ? ( + + ) : ( + + )} +
+ )} +
+ {error && (
{error}
)} diff --git a/zettelkasten-front/src/components/entities/EntityCard.tsx b/zettelkasten-front/src/components/entities/EntityCard.tsx index c48c8d6..d0e760e 100644 --- a/zettelkasten-front/src/components/entities/EntityCard.tsx +++ b/zettelkasten-front/src/components/entities/EntityCard.tsx @@ -1,5 +1,7 @@ import React from "react"; import { Entity } from "../../models/Card"; +import { Link } from "react-router-dom"; +import { CardTag } from "../cards/CardTag"; interface EntityCardProps { entity: Entity; @@ -39,7 +41,15 @@ export function EntityCard({ entity, isSelected, selectionInfo, onEdit, onClick
Cards: {entity.card_count} - Updated: {entity.updated_at.toLocaleDateString()} + {entity.card && ( + e.stopPropagation()} + className="text-blue-600 hover:text-blue-800" + > + + + )}
{selectionInfo && ( diff --git a/zettelkasten-front/src/models/Card.ts b/zettelkasten-front/src/models/Card.ts index 8c0ebae..a4d1d57 100644 --- a/zettelkasten-front/src/models/Card.ts +++ b/zettelkasten-front/src/models/Card.ts @@ -22,6 +22,8 @@ export interface Entity { created_at: Date; updated_at: Date; card_count: number; + card_pk: number | null; + card?: PartialCard; } export interface Card { diff --git a/zettelkasten-front/src/tests/data.ts b/zettelkasten-front/src/tests/data.ts index 90bf27b..ae17891 100644 --- a/zettelkasten-front/src/tests/data.ts +++ b/zettelkasten-front/src/tests/data.ts @@ -144,7 +144,8 @@ export const sampleEntityData: Entity[] = [ type: "Type A", created_at: new Date(), updated_at: new Date(), - card_count: 1 + card_count: 1, + card_pk: null }, { id: 2, @@ -154,7 +155,8 @@ export const sampleEntityData: Entity[] = [ type: "Type B", created_at: new Date(), updated_at: new Date(), - card_count: 2 + card_count: 2, + card_pk: null }, ];