feat(server): add brand kit management and project binding

Add a brand kit domain with memory and postgres stores, a BrandKitService
for CRUD and resolution, and REST endpoints to list, upsert, and delete
brand kits. Projects can bind a brand kit whose resolved context and
reference images feed into the agent's long-term memory and design prompts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:58:55 +08:00
parent 3653474355
commit 789f51b29e
38 changed files with 2165 additions and 135 deletions
@@ -0,0 +1,105 @@
package memory
import (
"context"
"sort"
"sync"
"img_infinite_canvas/internal/domain/brandkit"
)
type BrandKitStore struct {
mu sync.RWMutex
kits map[string]brandkit.Kit
}
func NewBrandKitStore() *BrandKitStore {
return &BrandKitStore{kits: make(map[string]brandkit.Kit)}
}
func (s *BrandKitStore) List(ctx context.Context, userID string) ([]brandkit.Kit, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]brandkit.Kit, 0)
for _, kit := range s.kits {
if kit.UserID == userID {
items = append(items, brandkit.Clone(kit))
}
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].UpdatedAt.Equal(items[j].UpdatedAt) {
return items[i].ID < items[j].ID
}
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
return items, nil
}
func (s *BrandKitStore) Get(ctx context.Context, userID string, id string) (brandkit.Kit, error) {
if err := ctx.Err(); err != nil {
return brandkit.Kit{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
kit, ok := s.kits[id]
if !ok || kit.UserID != userID {
return brandkit.Kit{}, brandkit.ErrNotFound
}
return brandkit.Clone(kit), nil
}
func (s *BrandKitStore) GetDefault(ctx context.Context, userID string) (brandkit.Kit, error) {
if err := ctx.Err(); err != nil {
return brandkit.Kit{}, err
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, kit := range s.kits {
if kit.UserID == userID && kit.IsDefault {
return brandkit.Clone(kit), nil
}
}
return brandkit.Kit{}, brandkit.ErrNotFound
}
func (s *BrandKitStore) Upsert(ctx context.Context, kit brandkit.Kit) (brandkit.Kit, error) {
if err := ctx.Err(); err != nil {
return brandkit.Kit{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.kits[kit.ID]; ok && existing.UserID != kit.UserID {
return brandkit.Kit{}, brandkit.ErrConflict
}
if kit.IsDefault {
for id, candidate := range s.kits {
if candidate.UserID == kit.UserID && candidate.IsDefault && id != kit.ID {
candidate.IsDefault = false
s.kits[id] = candidate
}
}
}
s.kits[kit.ID] = brandkit.Clone(kit)
return brandkit.Clone(kit), nil
}
func (s *BrandKitStore) Delete(ctx context.Context, userID string, id string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
kit, ok := s.kits[id]
if !ok || kit.UserID != userID {
return brandkit.ErrNotFound
}
delete(s.kits, id)
return nil
}
func (s *BrandKitStore) Close() error {
return nil
}
@@ -38,13 +38,14 @@ func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary,
continue
}
items = append(items, design.ProjectSummary{
ID: project.ID,
UserID: project.UserID,
Title: project.Title,
Brief: project.Brief,
Status: project.Status,
Thumbnail: project.Thumbnail,
UpdatedAt: project.UpdatedAt,
ID: project.ID,
UserID: project.UserID,
Title: project.Title,
Brief: project.Brief,
Status: project.Status,
Thumbnail: project.Thumbnail,
BrandKitID: project.BrandKitID,
UpdatedAt: project.UpdatedAt,
})
}
return items, nil
@@ -207,6 +208,7 @@ func repositoryUserID(ctx context.Context, userID string) string {
func cloneProject(project design.Project) design.Project {
project.UserID = repositoryUserID(context.Background(), project.UserID)
project.BrandImages = append([]string(nil), project.BrandImages...)
project.Nodes = append([]design.Node(nil), project.Nodes...)
project.Connections = append([]design.Connection(nil), project.Connections...)
project.Messages = append([]design.Message(nil), project.Messages...)
@@ -0,0 +1,48 @@
package piagent
import (
"strings"
"testing"
"img_infinite_canvas/internal/domain/design"
)
func TestBrandKitContextReachesPlannerAndDirectImagePrompts(t *testing.T) {
project := design.Project{
Title: "Campaign",
Brief: "Launch visual",
BrandKitID: "brand-kit-alpha",
BrandContext: "Primary color: Signal Green #28C76F. Typography: Avenir Next.",
}
planner := projectBriefContext(project)
direct := directImagePrompt(design.AgentRequest{Project: project, Prompt: "Create a product hero"})
fallback := fallbackImagePrompt(design.AgentRequest{
Project: project,
Prompt: "Create a product hero",
TaskPlan: design.AgentTaskPlan{ImageTasks: []design.AgentImageTask{
{Title: "Hero", Brief: "Centered product"},
}},
}, 0)
for name, value := range map[string]string{"planner": planner, "direct": direct, "fallback": fallback} {
if !strings.Contains(value, "Signal Green #28C76F") || !strings.Contains(value, "Avenir Next") {
t.Fatalf("%s prompt omitted Brand Kit context: %s", name, value)
}
}
}
func TestBrandKitImagesFollowUserReferences(t *testing.T) {
req := design.AgentRequest{
Project: design.Project{BrandImages: []string{
"data:image/png;base64,YnJhbmQtbG9nbw==",
"data:image/webp;base64,YnJhbmQtcmVmZXJlbmNl",
}},
Messages: []design.AgentChatMessage{{Contents: []design.AgentContent{{Type: "image", ImageURL: "https://example.com/user-reference.png"}}}},
}
images := referenceImageURLs(req)
if len(images) != 3 {
t.Fatalf("expected user and Brand Kit references, got %#v", images)
}
if images[0] != "https://example.com/user-reference.png" || images[1] != req.Project.BrandImages[0] || images[2] != req.Project.BrandImages[1] {
t.Fatalf("expected user references before Brand Kit images, got %#v", images)
}
}
@@ -843,6 +843,11 @@ func projectBriefContext(project design.Project) string {
builder.WriteString(project.Brief)
builder.WriteString("\n")
}
if strings.TrimSpace(project.BrandContext) != "" {
builder.WriteString("Brand Kit context:\n")
builder.WriteString(strings.TrimSpace(project.BrandContext))
builder.WriteString("\n")
}
builder.WriteString(fmt.Sprintf("Canvas nodes: %d", len(project.Nodes)))
return builder.String()
}
@@ -210,7 +210,7 @@ func directImagePlan(req design.AgentRequest) design.AgentPlan {
func directImagePrompts(req design.AgentRequest) []design.AgentImagePrompt {
count := plannedGenerationCount(req)
prompt := strings.TrimSpace(req.Prompt)
prompt := directImagePrompt(req)
if prompt == "" {
prompt = fallbackDirectImagePrompt(req)
}
@@ -257,7 +257,7 @@ func directImageTasksSharePrompt(req design.AgentRequest) bool {
}
func directImageBatchPrompt(req design.AgentRequest) design.AgentImagePrompt {
prompt := strings.TrimSpace(req.Prompt)
prompt := directImagePrompt(req)
if prompt == "" {
prompt = fallbackDirectImagePrompt(req)
}
@@ -614,6 +614,12 @@ func referenceImageURLs(req design.AgentRequest) []string {
}
}
}
for _, image := range req.Project.BrandImages {
add(image)
if len(urls) >= 16 {
return urls[:16]
}
}
if len(urls) > 16 {
return urls[:16]
}
@@ -662,9 +668,9 @@ func fallbackImagePrompt(req design.AgentRequest, index int) string {
task := req.TaskPlan.ImageTasks[index]
if brief := strings.TrimSpace(task.Brief); brief != "" {
if title := strings.TrimSpace(task.Title); title != "" && !strings.Contains(brief, title) {
return title + ": " + brief
return imagePromptWithBrandKit(title+": "+brief, req)
}
return brief
return imagePromptWithBrandKit(brief, req)
}
}
return fallbackDirectImagePrompt(req)
@@ -1064,6 +1070,9 @@ func fallbackDirectImagePrompt(req design.AgentRequest) string {
if brief := strings.TrimSpace(req.Project.Brief); brief != "" {
lines = append(lines, "Project brief: "+truncateRunes(brief, 180))
}
if brandContext := strings.TrimSpace(req.Project.BrandContext); brandContext != "" {
lines = append(lines, "Binding Brand Kit: "+truncateRunes(brandContext, 520))
}
if memory := compactFallbackMemory(req.Memory); memory != "" {
lines = append(lines, "Relevant memory: "+memory)
}
@@ -1080,6 +1089,19 @@ func fallbackDirectImagePrompt(req design.AgentRequest) string {
return compactImageGenerationPrompt(strings.Join(lines, "\n"), imageGenerationFallbackMaxRunes)
}
func directImagePrompt(req design.AgentRequest) string {
return imagePromptWithBrandKit(req.Prompt, req)
}
func imagePromptWithBrandKit(prompt string, req design.AgentRequest) string {
prompt = strings.TrimSpace(prompt)
brandContext := strings.TrimSpace(req.Project.BrandContext)
if brandContext == "" {
return prompt
}
return strings.TrimSpace(prompt + "\n\nBinding Brand Kit requirements:\n" + brandContext)
}
func compactFallbackMemory(memory design.AgentMemory) string {
if memory.IsZero() {
return ""
@@ -0,0 +1,128 @@
package postgres
import (
"context"
"errors"
"img_infinite_canvas/internal/domain/brandkit"
sqlcdb "img_infinite_canvas/internal/infrastructure/postgres/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type BrandKitStore struct {
pool *pgxpool.Pool
}
func NewBrandKitStore(ctx context.Context, dataSource string) (*BrandKitStore, 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 &BrandKitStore{pool: pool}, nil
}
func (s *BrandKitStore) List(ctx context.Context, userID string) ([]brandkit.Kit, error) {
rows, err := sqlcdb.New(s.pool).ListBrandKits(ctx, toPGUUID(userID))
if err != nil {
return nil, err
}
items := make([]brandkit.Kit, 0, len(rows))
for _, row := range rows {
items = append(items, mapBrandKit(row))
}
return items, nil
}
func (s *BrandKitStore) Get(ctx context.Context, userID string, id string) (brandkit.Kit, error) {
row, err := sqlcdb.New(s.pool).GetBrandKit(ctx, sqlcdb.GetBrandKitParams{ID: id, UserID: toPGUUID(userID)})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return brandkit.Kit{}, brandkit.ErrNotFound
}
return brandkit.Kit{}, err
}
return mapBrandKit(row), nil
}
func (s *BrandKitStore) GetDefault(ctx context.Context, userID string) (brandkit.Kit, error) {
row, err := sqlcdb.New(s.pool).GetDefaultBrandKit(ctx, toPGUUID(userID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return brandkit.Kit{}, brandkit.ErrNotFound
}
return brandkit.Kit{}, err
}
return mapBrandKit(row), nil
}
func (s *BrandKitStore) Upsert(ctx context.Context, kit brandkit.Kit) (brandkit.Kit, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return brandkit.Kit{}, err
}
defer tx.Rollback(ctx)
q := sqlcdb.New(s.pool).WithTx(tx)
if kit.IsDefault {
if err := q.ClearDefaultBrandKits(ctx, sqlcdb.ClearDefaultBrandKitsParams{UserID: toPGUUID(kit.UserID), ID: kit.ID}); err != nil {
return brandkit.Kit{}, err
}
}
row, err := q.UpsertBrandKit(ctx, sqlcdb.UpsertBrandKitParams{
ID: kit.ID,
UserID: toPGUUID(kit.UserID),
Name: kit.Name,
Document: append([]byte(nil), kit.Document...),
IsDefault: kit.IsDefault,
CreatedAt: pgtype.Timestamptz{Time: kit.CreatedAt, Valid: true},
UpdatedAt: pgtype.Timestamptz{Time: kit.UpdatedAt, Valid: true},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.Is(err, pgx.ErrNoRows) {
return brandkit.Kit{}, brandkit.ErrNotFound
}
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return brandkit.Kit{}, brandkit.ErrConflict
}
return brandkit.Kit{}, err
}
if err := tx.Commit(ctx); err != nil {
return brandkit.Kit{}, err
}
return mapBrandKit(row), nil
}
func (s *BrandKitStore) Delete(ctx context.Context, userID string, id string) error {
_, err := sqlcdb.New(s.pool).DeleteBrandKit(ctx, sqlcdb.DeleteBrandKitParams{ID: id, UserID: toPGUUID(userID)})
if errors.Is(err, pgx.ErrNoRows) {
return brandkit.ErrNotFound
}
return err
}
func (s *BrandKitStore) Close() error {
if s != nil && s.pool != nil {
s.pool.Close()
}
return nil
}
func mapBrandKit(row sqlcdb.BrandKit) brandkit.Kit {
return brandkit.Kit{
ID: row.ID,
UserID: fromPGUUID(row.UserID),
Name: row.Name,
Document: append([]byte(nil), row.Document...),
IsDefault: row.IsDefault,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
@@ -26,6 +26,29 @@ type AgentMessage struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AuthVerificationCode struct {
ID string `json:"id"`
Region string `json:"region"`
Channel string `json:"channel"`
Target string `json:"target"`
Purpose string `json:"purpose"`
CodeHash string `json:"code_hash"`
Attempts int32 `json:"attempts"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type BrandKit struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
Document []byte `json:"document"`
IsDefault bool `json:"is_default"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CanvasConnection struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
@@ -81,6 +104,14 @@ type CanvasSnapshot struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type MockupBlob struct {
DataID string `json:"data_id"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Data []byte `json:"data"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type OutboxEvent struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
@@ -105,17 +136,61 @@ type Project struct {
Version string `json:"version"`
SnapshotID string `json:"snapshot_id"`
LastThreadID string `json:"last_thread_id"`
BrandKitID pgtype.Text `json:"brand_kit_id"`
ViewportX float64 `json:"viewport_x"`
ViewportY float64 `json:"viewport_y"`
ViewportK float64 `json:"viewport_k"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
type ProjectShareMember struct {
ID pgtype.UUID `json:"id"`
ProjectID string `json:"project_id"`
IdentifierType string `json:"identifier_type"`
IdentifierHash []byte `json:"identifier_hash"`
IdentifierCiphertext []byte `json:"identifier_ciphertext"`
Permission string `json:"permission"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ProjectSharePolicy struct {
ProjectID string `json:"project_id"`
OwnerID pgtype.UUID `json:"owner_id"`
TokenHash []byte `json:"token_hash"`
TokenCiphertext []byte `json:"token_ciphertext"`
LinkPermission string `json:"link_permission"`
PolicyVersion int64 `json:"policy_version"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ProjectShareVisit struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
VisitorHash []byte `json:"visitor_hash"`
UserID pgtype.UUID `json:"user_id"`
VisitCount int64 `json:"visit_count"`
FirstSeenAt pgtype.Timestamptz `json:"first_seen_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Region string `json:"region"`
PhoneCountryCode string `json:"phone_country_code"`
Phone string `json:"phone"`
AvatarUrl string `json:"avatar_url"`
PasswordHash string `json:"password_hash"`
WechatOpenID string `json:"wechat_open_id"`
WechatUnionID string `json:"wechat_union_id"`
GoogleSubject string `json:"google_subject"`
PhoneVerifiedAt pgtype.Timestamptz `json:"phone_verified_at"`
EmailVerifiedAt pgtype.Timestamptz `json:"email_verified_at"`
LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
}
@@ -11,15 +11,20 @@ import (
)
type Querier interface {
ClearDefaultBrandKits(ctx context.Context, arg ClearDefaultBrandKitsParams) error
CreateCanvasSnapshot(ctx context.Context, arg CreateCanvasSnapshotParams) error
CreateConnection(ctx context.Context, arg CreateConnectionParams) error
CreateMessage(ctx context.Context, arg CreateMessageParams) error
DeleteBrandKit(ctx context.Context, arg DeleteBrandKitParams) (string, error)
DeleteConnectionsByProject(ctx context.Context, arg DeleteConnectionsByProjectParams) error
DeleteMessagesByProject(ctx context.Context, arg DeleteMessagesByProjectParams) error
DeleteNodesByProject(ctx context.Context, arg DeleteNodesByProjectParams) error
DeleteProject(ctx context.Context, arg DeleteProjectParams) error
GetBrandKit(ctx context.Context, arg GetBrandKitParams) (BrandKit, error)
GetCanvasSnapshot(ctx context.Context, arg GetCanvasSnapshotParams) (CanvasSnapshot, error)
GetDefaultBrandKit(ctx context.Context, userID pgtype.UUID) (BrandKit, error)
GetProject(ctx context.Context, arg GetProjectParams) (Project, error)
ListBrandKits(ctx context.Context, userID pgtype.UUID) ([]BrandKit, error)
ListConnectionsByProject(ctx context.Context, arg ListConnectionsByProjectParams) ([]CanvasConnection, error)
ListMessagesByProject(ctx context.Context, arg ListMessagesByProjectParams) ([]AgentMessage, error)
ListNodesByProject(ctx context.Context, arg ListNodesByProjectParams) ([]CanvasNode, error)
@@ -27,6 +32,7 @@ type Querier interface {
ListProjectsPage(ctx context.Context, arg ListProjectsPageParams) ([]ListProjectsPageRow, error)
LockProject(ctx context.Context, arg LockProjectParams) (LockProjectRow, error)
UpdateProjectCanvasSnapshot(ctx context.Context, arg UpdateProjectCanvasSnapshotParams) error
UpsertBrandKit(ctx context.Context, arg UpsertBrandKitParams) (BrandKit, error)
UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodeParams) error
UpsertProject(ctx context.Context, arg UpsertProjectParams) error
}
@@ -11,6 +11,22 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const clearDefaultBrandKits = `-- name: ClearDefaultBrandKits :exec
UPDATE brand_kits
SET is_default = FALSE
WHERE user_id = $1 AND is_default = TRUE AND id <> $2
`
type ClearDefaultBrandKitsParams struct {
UserID pgtype.UUID `json:"user_id"`
ID string `json:"id"`
}
func (q *Queries) ClearDefaultBrandKits(ctx context.Context, arg ClearDefaultBrandKitsParams) error {
_, err := q.db.Exec(ctx, clearDefaultBrandKits, arg.UserID, arg.ID)
return err
}
const createCanvasSnapshot = `-- name: CreateCanvasSnapshot :exec
INSERT INTO canvas_snapshots (id, project_id, user_id, version, canvas, viewport_json, nodes_json, connections_json, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
@@ -140,6 +156,24 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) er
return err
}
const deleteBrandKit = `-- name: DeleteBrandKit :one
DELETE FROM brand_kits
WHERE id = $1 AND user_id = $2
RETURNING id
`
type DeleteBrandKitParams struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) DeleteBrandKit(ctx context.Context, arg DeleteBrandKitParams) (string, error) {
row := q.db.QueryRow(ctx, deleteBrandKit, arg.ID, arg.UserID)
var id string
err := row.Scan(&id)
return id, err
}
const deleteConnectionsByProject = `-- name: DeleteConnectionsByProject :exec
DELETE FROM canvas_connections
WHERE project_id = $1 AND user_id = $2
@@ -200,6 +234,32 @@ func (q *Queries) DeleteProject(ctx context.Context, arg DeleteProjectParams) er
return err
}
const getBrandKit = `-- name: GetBrandKit :one
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE id = $1 AND user_id = $2
`
type GetBrandKitParams struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetBrandKit(ctx context.Context, arg GetBrandKitParams) (BrandKit, error) {
row := q.db.QueryRow(ctx, getBrandKit, arg.ID, arg.UserID)
var i BrandKit
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Document,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCanvasSnapshot = `-- name: GetCanvasSnapshot :one
SELECT id, project_id, user_id, version, canvas, viewport_json, nodes_json, connections_json, created_at
FROM canvas_snapshots
@@ -229,8 +289,30 @@ func (q *Queries) GetCanvasSnapshot(ctx context.Context, arg GetCanvasSnapshotPa
return i, err
}
const getDefaultBrandKit = `-- name: GetDefaultBrandKit :one
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE user_id = $1 AND is_default = TRUE
LIMIT 1
`
func (q *Queries) GetDefaultBrandKit(ctx context.Context, userID pgtype.UUID) (BrandKit, error) {
row := q.db.QueryRow(ctx, getDefaultBrandKit, userID)
var i BrandKit
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Document,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProject = `-- name: GetProject :one
SELECT id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, viewport_x, viewport_y, viewport_k, updated_at
SELECT id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, brand_kit_id, viewport_x, viewport_y, viewport_k, updated_at
FROM projects
WHERE id = $1 AND user_id = $2
`
@@ -254,6 +336,7 @@ func (q *Queries) GetProject(ctx context.Context, arg GetProjectParams) (Project
&i.Version,
&i.SnapshotID,
&i.LastThreadID,
&i.BrandKitID,
&i.ViewportX,
&i.ViewportY,
&i.ViewportK,
@@ -262,6 +345,41 @@ func (q *Queries) GetProject(ctx context.Context, arg GetProjectParams) (Project
return i, err
}
const listBrandKits = `-- name: ListBrandKits :many
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE user_id = $1
ORDER BY updated_at DESC, id ASC
`
func (q *Queries) ListBrandKits(ctx context.Context, userID pgtype.UUID) ([]BrandKit, error) {
rows, err := q.db.Query(ctx, listBrandKits, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []BrandKit
for rows.Next() {
var i BrandKit
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Document,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listConnectionsByProject = `-- name: ListConnectionsByProject :many
SELECT id, project_id, user_id, from_node_id, to_node_id
FROM canvas_connections
@@ -414,20 +532,21 @@ func (q *Queries) ListNodesByProject(ctx context.Context, arg ListNodesByProject
}
const listProjects = `-- name: ListProjects :many
SELECT id, user_id, title, brief, status, thumbnail, updated_at
SELECT id, user_id, title, brief, status, thumbnail, brand_kit_id, updated_at
FROM projects
WHERE user_id = $1
ORDER BY updated_at DESC
`
type ListProjectsRow struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Title string `json:"title"`
Brief string `json:"brief"`
Status string `json:"status"`
Thumbnail string `json:"thumbnail"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Title string `json:"title"`
Brief string `json:"brief"`
Status string `json:"status"`
Thumbnail string `json:"thumbnail"`
BrandKitID pgtype.Text `json:"brand_kit_id"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjects(ctx context.Context, userID pgtype.UUID) ([]ListProjectsRow, error) {
@@ -446,6 +565,7 @@ func (q *Queries) ListProjects(ctx context.Context, userID pgtype.UUID) ([]ListP
&i.Brief,
&i.Status,
&i.Thumbnail,
&i.BrandKitID,
&i.UpdatedAt,
); err != nil {
return nil, err
@@ -459,7 +579,7 @@ func (q *Queries) ListProjects(ctx context.Context, userID pgtype.UUID) ([]ListP
}
const listProjectsPage = `-- name: ListProjectsPage :many
SELECT id, user_id, title, brief, status, thumbnail, updated_at
SELECT id, user_id, title, brief, status, thumbnail, brand_kit_id, updated_at
FROM projects
WHERE user_id = $1
ORDER BY updated_at DESC
@@ -473,13 +593,14 @@ type ListProjectsPageParams struct {
}
type ListProjectsPageRow struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Title string `json:"title"`
Brief string `json:"brief"`
Status string `json:"status"`
Thumbnail string `json:"thumbnail"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Title string `json:"title"`
Brief string `json:"brief"`
Status string `json:"status"`
Thumbnail string `json:"thumbnail"`
BrandKitID pgtype.Text `json:"brand_kit_id"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjectsPage(ctx context.Context, arg ListProjectsPageParams) ([]ListProjectsPageRow, error) {
@@ -498,6 +619,7 @@ func (q *Queries) ListProjectsPage(ctx context.Context, arg ListProjectsPagePara
&i.Brief,
&i.Status,
&i.Thumbnail,
&i.BrandKitID,
&i.UpdatedAt,
); err != nil {
return nil, err
@@ -577,6 +699,51 @@ func (q *Queries) UpdateProjectCanvasSnapshot(ctx context.Context, arg UpdatePro
return err
}
const upsertBrandKit = `-- name: UpsertBrandKit :one
INSERT INTO brand_kits (id, user_id, name, document, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
document = EXCLUDED.document,
is_default = EXCLUDED.is_default,
updated_at = EXCLUDED.updated_at
WHERE brand_kits.user_id = EXCLUDED.user_id
RETURNING id, user_id, name, document, is_default, created_at, updated_at
`
type UpsertBrandKitParams struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
Document []byte `json:"document"`
IsDefault bool `json:"is_default"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) UpsertBrandKit(ctx context.Context, arg UpsertBrandKitParams) (BrandKit, error) {
row := q.db.QueryRow(ctx, upsertBrandKit,
arg.ID,
arg.UserID,
arg.Name,
arg.Document,
arg.IsDefault,
arg.CreatedAt,
arg.UpdatedAt,
)
var i BrandKit
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Document,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertCanvasNode = `-- name: UpsertCanvasNode :exec
INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)
@@ -688,8 +855,8 @@ func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodePara
}
const upsertProject = `-- name: UpsertProject :exec
INSERT INTO projects (id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, viewport_x, viewport_y, viewport_k, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
INSERT INTO projects (id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, brand_kit_id, viewport_x, viewport_y, viewport_k, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
title = EXCLUDED.title,
@@ -700,6 +867,7 @@ ON CONFLICT (id) DO UPDATE SET
version = EXCLUDED.version,
snapshot_id = EXCLUDED.snapshot_id,
last_thread_id = EXCLUDED.last_thread_id,
brand_kit_id = EXCLUDED.brand_kit_id,
viewport_x = EXCLUDED.viewport_x,
viewport_y = EXCLUDED.viewport_y,
viewport_k = EXCLUDED.viewport_k,
@@ -717,6 +885,7 @@ type UpsertProjectParams struct {
Version string `json:"version"`
SnapshotID string `json:"snapshot_id"`
LastThreadID string `json:"last_thread_id"`
BrandKitID pgtype.Text `json:"brand_kit_id"`
ViewportX float64 `json:"viewport_x"`
ViewportY float64 `json:"viewport_y"`
ViewportK float64 `json:"viewport_k"`
@@ -735,6 +904,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er
arg.Version,
arg.SnapshotID,
arg.LastThreadID,
arg.BrandKitID,
arg.ViewportX,
arg.ViewportY,
arg.ViewportK,
@@ -50,13 +50,14 @@ func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary,
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,
ID: row.ID,
UserID: fromPGUUID(row.UserID),
Title: row.Title,
Brief: row.Brief,
Status: design.ProjectStatus(row.Status),
Thumbnail: row.Thumbnail,
BrandKitID: fromPGText(row.BrandKitID),
UpdatedAt: row.UpdatedAt.Time,
})
}
return items, nil
@@ -81,13 +82,14 @@ func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset in
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,
ID: row.ID,
UserID: fromPGUUID(row.UserID),
Title: row.Title,
Brief: row.Brief,
Status: design.ProjectStatus(row.Status),
Thumbnail: row.Thumbnail,
BrandKitID: fromPGText(row.BrandKitID),
UpdatedAt: row.UpdatedAt.Time,
})
}
return items, nil
@@ -129,6 +131,7 @@ func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project,
SnapshotID: row.SnapshotID,
Canvas: row.Canvas,
LastThreadID: row.LastThreadID,
BrandKitID: fromPGText(row.BrandKitID),
UpdatedAt: row.UpdatedAt.Time,
Viewport: design.Viewport{
X: row.ViewportX,
@@ -165,6 +168,7 @@ func (r *ProjectRepository) Save(ctx context.Context, project design.Project) er
Version: project.Version,
SnapshotID: project.SnapshotID,
LastThreadID: project.LastThreadID,
BrandKitID: toPGText(project.BrandKitID),
ViewportX: project.Viewport.X,
ViewportY: project.Viewport.Y,
ViewportK: project.Viewport.K,
@@ -463,6 +467,18 @@ func fromPGUUID(value pgtype.UUID) string {
return uuid.UUID(value.Bytes).String()
}
func toPGText(value string) pgtype.Text {
value = strings.TrimSpace(value)
return pgtype.Text{String: value, Valid: value != ""}
}
func fromPGText(value pgtype.Text) string {
if !value.Valid {
return ""
}
return value.String
}
func insertProjectOutboxEvent(ctx context.Context, tx pgx.Tx, project design.Project, eventType string) error {
eventID := uuid.NewString()
createdAt := project.UpdatedAt
@@ -1,18 +1,18 @@
-- name: ListProjects :many
SELECT id, user_id, title, brief, status, thumbnail, updated_at
SELECT id, user_id, title, brief, status, thumbnail, brand_kit_id, updated_at
FROM projects
WHERE user_id = $1
ORDER BY updated_at DESC;
-- name: ListProjectsPage :many
SELECT id, user_id, title, brief, status, thumbnail, updated_at
SELECT id, user_id, title, brief, status, thumbnail, brand_kit_id, updated_at
FROM projects
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3;
-- name: GetProject :one
SELECT id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, viewport_x, viewport_y, viewport_k, updated_at
SELECT id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, brand_kit_id, viewport_x, viewport_y, viewport_k, updated_at
FROM projects
WHERE id = $1 AND user_id = $2;
@@ -27,8 +27,8 @@ DELETE FROM projects
WHERE id = $1 AND user_id = $2;
-- name: UpsertProject :exec
INSERT INTO projects (id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, viewport_x, viewport_y, viewport_k, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
INSERT INTO projects (id, user_id, title, brief, status, thumbnail, canvas, version, snapshot_id, last_thread_id, brand_kit_id, viewport_x, viewport_y, viewport_k, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
title = EXCLUDED.title,
@@ -39,11 +39,50 @@ ON CONFLICT (id) DO UPDATE SET
version = EXCLUDED.version,
snapshot_id = EXCLUDED.snapshot_id,
last_thread_id = EXCLUDED.last_thread_id,
brand_kit_id = EXCLUDED.brand_kit_id,
viewport_x = EXCLUDED.viewport_x,
viewport_y = EXCLUDED.viewport_y,
viewport_k = EXCLUDED.viewport_k,
updated_at = EXCLUDED.updated_at;
-- name: ListBrandKits :many
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE user_id = $1
ORDER BY updated_at DESC, id ASC;
-- name: GetBrandKit :one
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE id = $1 AND user_id = $2;
-- name: GetDefaultBrandKit :one
SELECT id, user_id, name, document, is_default, created_at, updated_at
FROM brand_kits
WHERE user_id = $1 AND is_default = TRUE
LIMIT 1;
-- name: ClearDefaultBrandKits :exec
UPDATE brand_kits
SET is_default = FALSE
WHERE user_id = $1 AND is_default = TRUE AND id <> $2;
-- name: UpsertBrandKit :one
INSERT INTO brand_kits (id, user_id, name, document, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
document = EXCLUDED.document,
is_default = EXCLUDED.is_default,
updated_at = EXCLUDED.updated_at
WHERE brand_kits.user_id = EXCLUDED.user_id
RETURNING id, user_id, name, document, is_default, created_at, updated_at;
-- name: DeleteBrandKit :one
DELETE FROM brand_kits
WHERE id = $1 AND user_id = $2
RETURNING id;
-- name: CreateCanvasSnapshot :exec
INSERT INTO canvas_snapshots (id, project_id, user_id, version, canvas, viewport_json, nodes_json, connections_json, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
@@ -43,6 +43,19 @@ CREATE TABLE IF NOT EXISTS auth_verification_codes (
CREATE INDEX IF NOT EXISTS auth_codes_target_idx ON auth_verification_codes(region, channel, target, purpose, created_at DESC);
CREATE TABLE IF NOT EXISTS brand_kits (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
document JSONB NOT NULL CHECK (jsonb_typeof(document) = 'object'),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS brand_kits_user_updated_idx ON brand_kits(user_id, updated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS brand_kits_user_default_idx ON brand_kits(user_id) WHERE is_default;
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
@@ -54,6 +67,7 @@ CREATE TABLE IF NOT EXISTS projects (
version TEXT NOT NULL DEFAULT '',
snapshot_id TEXT NOT NULL DEFAULT '',
last_thread_id TEXT NOT NULL DEFAULT '',
brand_kit_id TEXT REFERENCES brand_kits(id) ON DELETE SET NULL,
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_k DOUBLE PRECISION NOT NULL DEFAULT 1,
@@ -70,7 +84,22 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS canvas TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS version TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS snapshot_id TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS last_thread_id TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS brand_kit_id TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'projects_brand_kit_id_fkey'
AND conrelid = 'projects'::regclass
) THEN
ALTER TABLE projects
ADD CONSTRAINT projects_brand_kit_id_fkey
FOREIGN KEY (brand_kit_id) REFERENCES brand_kits(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS projects_user_updated_idx ON projects(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS projects_brand_kit_idx ON projects(user_id, brand_kit_id) WHERE brand_kit_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS project_share_policies (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,