package postgres import ( "context" _ "embed" "encoding/json" "errors" "fmt" "strings" "time" "img_infinite_canvas/internal/domain/design" sqlcdb "img_infinite_canvas/internal/infrastructure/postgres/db" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed schema.sql var schemaSQL string type ProjectRepository struct { pool *pgxpool.Pool } func NewProjectRepository(ctx context.Context, dataSource string) (*ProjectRepository, error) { pool, err := pgxpool.New(ctx, dataSource) if err != nil { return nil, err } if _, err := pool.Exec(ctx, schemaSQL); err != nil { pool.Close() return nil, err } return &ProjectRepository{pool: pool}, nil } func (r *ProjectRepository) Close() { r.pool.Close() } func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) { rows, err := sqlcdb.New(r.pool).ListProjects(ctx, toPGUUID(design.UserIDFromContext(ctx))) if err != nil { return nil, err } items := make([]design.ProjectSummary, 0, len(rows)) for _, row := range rows { items = append(items, design.ProjectSummary{ ID: row.ID, UserID: fromPGUUID(row.UserID), Title: row.Title, Brief: row.Brief, Status: design.ProjectStatus(row.Status), Thumbnail: row.Thumbnail, UpdatedAt: row.UpdatedAt.Time, }) } return items, nil } func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset int64) ([]design.ProjectSummary, error) { if limit <= 0 { return []design.ProjectSummary{}, nil } if offset < 0 { offset = 0 } rows, err := sqlcdb.New(r.pool).ListProjectsPage(ctx, sqlcdb.ListProjectsPageParams{ UserID: toPGUUID(design.UserIDFromContext(ctx)), Limit: int32(limit), Offset: int32(offset), }) if err != nil { return nil, err } items := make([]design.ProjectSummary, 0, len(rows)) for _, row := range rows { items = append(items, design.ProjectSummary{ ID: row.ID, UserID: fromPGUUID(row.UserID), Title: row.Title, Brief: row.Brief, Status: design.ProjectStatus(row.Status), Thumbnail: row.Thumbnail, UpdatedAt: row.UpdatedAt.Time, }) } return items, nil } func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project, error) { q := sqlcdb.New(r.pool) userID := design.UserIDFromContext(ctx) pgUserID := toPGUUID(userID) row, err := q.GetProject(ctx, sqlcdb.GetProjectParams{ID: id, UserID: pgUserID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return design.Project{}, design.ErrNotFound } return design.Project{}, err } nodes, err := q.ListNodesByProject(ctx, sqlcdb.ListNodesByProjectParams{ProjectID: id, UserID: pgUserID}) if err != nil { return design.Project{}, err } connections, err := q.ListConnectionsByProject(ctx, sqlcdb.ListConnectionsByProjectParams{ProjectID: id, UserID: pgUserID}) if err != nil { return design.Project{}, err } messages, err := q.ListMessagesByProject(ctx, sqlcdb.ListMessagesByProjectParams{ProjectID: id, UserID: pgUserID}) if err != nil { return design.Project{}, err } project := design.Project{ ID: row.ID, UserID: fromPGUUID(row.UserID), Title: row.Title, Brief: row.Brief, Status: design.ProjectStatus(row.Status), Thumbnail: row.Thumbnail, Version: row.Version, SnapshotID: row.SnapshotID, Canvas: row.Canvas, LastThreadID: row.LastThreadID, UpdatedAt: row.UpdatedAt.Time, Viewport: design.Viewport{ X: row.ViewportX, Y: row.ViewportY, K: row.ViewportK, }, Nodes: mapNodes(nodes), Connections: mapConnections(connections), Messages: mapMessages(messages), } if err := r.applyCurrentSnapshot(ctx, q, &project); err != nil { return design.Project{}, err } return project, nil } func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error { project.UserID = repositoryUserID(ctx, project.UserID) tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) q := sqlcdb.New(r.pool).WithTx(tx) if err := q.UpsertProject(ctx, sqlcdb.UpsertProjectParams{ ID: project.ID, UserID: toPGUUID(project.UserID), Title: project.Title, Brief: project.Brief, Status: string(project.Status), Thumbnail: project.Thumbnail, Canvas: project.Canvas, Version: project.Version, SnapshotID: project.SnapshotID, LastThreadID: project.LastThreadID, ViewportX: project.Viewport.X, ViewportY: project.Viewport.Y, ViewportK: project.Viewport.K, UpdatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true}, }); err != nil { return err } if err := q.DeleteConnectionsByProject(ctx, sqlcdb.DeleteConnectionsByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil { return err } if err := q.DeleteNodesByProject(ctx, sqlcdb.DeleteNodesByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil { return err } if err := q.DeleteMessagesByProject(ctx, sqlcdb.DeleteMessagesByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil { return err } for index, node := range project.Nodes { if err := q.UpsertCanvasNode(ctx, sqlcdb.UpsertCanvasNodeParams{ ID: node.ID, ProjectID: project.ID, UserID: toPGUUID(project.UserID), NodeType: string(node.Type), Title: node.Title, X: node.X, Y: node.Y, Width: node.Width, Height: node.Height, Content: node.Content, Tone: node.Tone, Status: node.Status, SortOrder: int32(index), ParentID: node.ParentID, LayerRole: node.LayerRole, FontSize: node.FontSize, FontFamily: node.FontFamily, FontWeight: node.FontWeight, Color: node.Color, TextAlign: node.TextAlign, LineHeight: node.LineHeight, LetterSpacing: node.LetterSpacing, TextDecoration: node.TextDecoration, TextTransform: node.TextTransform, Opacity: node.Opacity, FillColor: node.FillColor, StrokeColor: node.StrokeColor, StrokeWidth: node.StrokeWidth, StrokeStyle: node.StrokeStyle, FlipX: node.FlipX, FlipY: node.FlipY, Rotation: node.Rotation, }); err != nil { return err } } for _, connection := range project.Connections { if err := q.CreateConnection(ctx, sqlcdb.CreateConnectionParams{ ID: connection.ID, ProjectID: project.ID, UserID: toPGUUID(project.UserID), FromNodeID: connection.FromNodeID, ToNodeID: connection.ToNodeID, }); err != nil { return err } } for _, message := range project.Messages { if err := q.CreateMessage(ctx, sqlcdb.CreateMessageParams{ ID: message.ID, ProjectID: project.ID, UserID: toPGUUID(project.UserID), Role: message.Role, MessageType: message.Type, Title: message.Title, Content: message.Content, ThreadID: message.ThreadID, ActionID: message.ActionID, StepID: message.StepID, ToolCallID: message.ToolCallID, Name: message.Name, ToolHint: message.ToolHint, Status: message.Status, CreatedAt: pgtype.Timestamptz{Time: message.CreatedAt, Valid: true}, }); err != nil { return err } } if err := r.createCanvasSnapshot(ctx, q, project); err != nil { return err } if err := insertProjectOutboxEvent(ctx, tx, project, "project.updated"); err != nil { return err } return tx.Commit(ctx) } func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error { project.UserID = repositoryUserID(ctx, project.UserID) tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) q := sqlcdb.New(r.pool).WithTx(tx) locked, err := q.LockProject(ctx, sqlcdb.LockProjectParams{ID: project.ID, UserID: toPGUUID(project.UserID)}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return design.ErrNotFound } return err } baseVersion = strings.TrimSpace(baseVersion) if baseVersion != "" && locked.Version != "" && baseVersion != locked.Version { return fmt.Errorf("%w: expected %s but current version is %s", design.ErrConflict, baseVersion, locked.Version) } if err := r.createCanvasSnapshot(ctx, q, project); err != nil { return err } if err := q.UpdateProjectCanvasSnapshot(ctx, sqlcdb.UpdateProjectCanvasSnapshotParams{ ID: project.ID, UserID: toPGUUID(project.UserID), Thumbnail: project.Thumbnail, Canvas: project.Canvas, Version: project.Version, SnapshotID: project.SnapshotID, ViewportX: project.Viewport.X, ViewportY: project.Viewport.Y, ViewportK: project.Viewport.K, UpdatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true}, }); err != nil { return err } if err := insertProjectOutboxEvent(ctx, tx, project, "canvas.snapshot.created"); err != nil { return err } return tx.Commit(ctx) } func (r *ProjectRepository) Delete(ctx context.Context, id string) error { q := sqlcdb.New(r.pool) userID := design.UserIDFromContext(ctx) pgUserID := toPGUUID(userID) project, err := q.GetProject(ctx, sqlcdb.GetProjectParams{ID: id, UserID: pgUserID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return design.ErrNotFound } return err } tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) qtx := sqlcdb.New(r.pool).WithTx(tx) if err := qtx.DeleteProject(ctx, sqlcdb.DeleteProjectParams{ID: id, UserID: pgUserID}); err != nil { return err } if err := insertProjectOutboxEvent(ctx, tx, design.Project{ID: project.ID, UserID: fromPGUUID(project.UserID), LastThreadID: project.LastThreadID, UpdatedAt: project.UpdatedAt.Time}, "project.deleted"); err != nil { return err } return tx.Commit(ctx) } func (r *ProjectRepository) SaveMockupBlob(ctx context.Context, blob design.MockupBlobData) error { if strings.TrimSpace(blob.DataID) == "" || len(blob.Data) == 0 { return design.ErrInvalidInput } createdAt := blob.CreatedAt if createdAt.IsZero() { createdAt = time.Now() } _, err := r.pool.Exec(ctx, ` INSERT INTO mockup_blobs (data_id, width, height, data, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (data_id) DO UPDATE SET width = EXCLUDED.width, height = EXCLUDED.height, data = EXCLUDED.data `, blob.DataID, blob.Width, blob.Height, blob.Data, pgtype.Timestamptz{Time: createdAt, Valid: true}) return err } func (r *ProjectRepository) GetMockupBlob(ctx context.Context, dataID string) (design.MockupBlobData, error) { dataID = strings.TrimSpace(dataID) if dataID == "" { return design.MockupBlobData{}, design.ErrNotFound } var blob design.MockupBlobData var createdAt pgtype.Timestamptz err := r.pool.QueryRow(ctx, ` SELECT data_id, width, height, data, created_at FROM mockup_blobs WHERE data_id = $1 `, dataID).Scan(&blob.DataID, &blob.Width, &blob.Height, &blob.Data, &createdAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return design.MockupBlobData{}, design.ErrNotFound } return design.MockupBlobData{}, err } if createdAt.Valid { blob.CreatedAt = createdAt.Time } blob.Data = append([]byte(nil), blob.Data...) return blob, nil } func (r *ProjectRepository) applyCurrentSnapshot(ctx context.Context, q *sqlcdb.Queries, project *design.Project) error { if project == nil || strings.TrimSpace(project.SnapshotID) == "" { return nil } snapshot, err := q.GetCanvasSnapshot(ctx, sqlcdb.GetCanvasSnapshotParams{ ID: project.SnapshotID, ProjectID: project.ID, UserID: toPGUUID(project.UserID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return err } var viewport design.Viewport var nodes []design.Node var connections []design.Connection if err := json.Unmarshal(snapshot.ViewportJson, &viewport); err != nil { return err } if err := json.Unmarshal(snapshot.NodesJson, &nodes); err != nil { return err } if err := json.Unmarshal(snapshot.ConnectionsJson, &connections); err != nil { return err } project.Viewport = viewport project.Nodes = nodes project.Connections = connections project.Canvas = snapshot.Canvas project.Version = snapshot.Version return nil } func (r *ProjectRepository) createCanvasSnapshot(ctx context.Context, q *sqlcdb.Queries, project design.Project) error { if strings.TrimSpace(project.SnapshotID) == "" || strings.TrimSpace(project.Canvas) == "" { return nil } viewport, err := json.Marshal(project.Viewport) if err != nil { return err } nodes, err := json.Marshal(project.Nodes) if err != nil { return err } connections, err := json.Marshal(project.Connections) if err != nil { return err } return q.CreateCanvasSnapshot(ctx, sqlcdb.CreateCanvasSnapshotParams{ ID: project.SnapshotID, ProjectID: project.ID, UserID: toPGUUID(project.UserID), Version: project.Version, Canvas: project.Canvas, ViewportJson: viewport, NodesJson: nodes, ConnectionsJson: connections, CreatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true}, }) } func repositoryUserID(ctx context.Context, userID string) string { userID = strings.TrimSpace(userID) if userID != "" { return design.NormalizeUserID(userID) } return design.UserIDFromContext(ctx) } func toPGUUID(userID string) pgtype.UUID { parsed, err := uuid.Parse(design.NormalizeUserID(userID)) if err != nil { parsed = uuid.MustParse(design.DefaultUserID) } return pgtype.UUID{Bytes: parsed, Valid: true} } func fromPGUUID(value pgtype.UUID) string { if !value.Valid { return design.DefaultUserID } return uuid.UUID(value.Bytes).String() } func insertProjectOutboxEvent(ctx context.Context, tx pgx.Tx, project design.Project, eventType string) error { eventID := uuid.NewString() createdAt := project.UpdatedAt if createdAt.IsZero() { createdAt = time.Now() } payload, err := json.Marshal(map[string]any{ "id": eventID, "type": eventType, "userId": project.UserID, "projectId": project.ID, "threadId": project.LastThreadID, "version": project.Version, "snapshotId": project.SnapshotID, "createdAt": createdAt.UTC().Format(time.RFC3339Nano), }) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO outbox_events (id, user_id, aggregate_type, aggregate_id, event_type, thread_id, payload_json, status, created_at) VALUES ($1, $2, 'project', $3, $4, $5, $6, 'pending', $7) `, eventID, toPGUUID(project.UserID), project.ID, eventType, project.LastThreadID, payload, pgtype.Timestamptz{Time: createdAt, Valid: true}) return err } func mapNodes(rows []sqlcdb.CanvasNode) []design.Node { nodes := make([]design.Node, 0, len(rows)) for _, row := range rows { nodes = append(nodes, design.Node{ ID: row.ID, Type: design.NodeType(row.NodeType), Title: row.Title, X: row.X, Y: row.Y, Width: row.Width, Height: row.Height, Content: row.Content, Tone: row.Tone, Status: row.Status, ParentID: row.ParentID, LayerRole: row.LayerRole, FontSize: row.FontSize, FontFamily: row.FontFamily, FontWeight: row.FontWeight, Color: row.Color, TextAlign: row.TextAlign, LineHeight: row.LineHeight, LetterSpacing: row.LetterSpacing, TextDecoration: row.TextDecoration, TextTransform: row.TextTransform, Opacity: row.Opacity, FillColor: row.FillColor, StrokeColor: row.StrokeColor, StrokeWidth: row.StrokeWidth, StrokeStyle: row.StrokeStyle, FlipX: row.FlipX, FlipY: row.FlipY, Rotation: row.Rotation, }) } return nodes } func mapConnections(rows []sqlcdb.CanvasConnection) []design.Connection { connections := make([]design.Connection, 0, len(rows)) for _, row := range rows { connections = append(connections, design.Connection{ ID: row.ID, FromNodeID: row.FromNodeID, ToNodeID: row.ToNodeID, }) } return connections } func mapMessages(rows []sqlcdb.AgentMessage) []design.Message { messages := make([]design.Message, 0, len(rows)) for _, row := range rows { messages = append(messages, design.Message{ ID: row.ID, Role: row.Role, Type: row.MessageType, Title: row.Title, Content: row.Content, ThreadID: row.ThreadID, ActionID: row.ActionID, StepID: row.StepID, ToolCallID: row.ToolCallID, Name: row.Name, ToolHint: row.ToolHint, Status: row.Status, CreatedAt: row.CreatedAt.Time, }) } return messages }