feat(documents): rebuild project document workbench

This commit is contained in:
2026-07-24 12:16:02 +08:00
parent 366136cee9
commit 77beaebb30
73 changed files with 5867 additions and 1264 deletions

View File

@@ -0,0 +1,85 @@
package models
import (
"time"
"gorm.io/gorm"
)
const (
DocumentKindFolder = "folder"
DocumentKindFile = "file"
)
type SaDocument struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null;uniqueIndex:uidx_sa_document_sibling,priority:1"`
ProjectIdentity string `gorm:"type:char(36);index"`
ParentID *uint `gorm:"index"`
ParentIdentity *string `gorm:"type:char(36);index"`
ParentScope string `gorm:"type:char(36);not null;default:'';uniqueIndex:uidx_sa_document_sibling,priority:2"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Kind string `gorm:"size:16;not null"`
Name string `gorm:"not null"`
NormalizedName string `gorm:"not null;uniqueIndex:uidx_sa_document_sibling,priority:3"`
Extension string `gorm:"size:32"`
MimeType string `gorm:"size:255"`
Size int64 `gorm:"not null;default:0"`
Revision uint64 `gorm:"not null;default:1"`
SortOrder int `gorm:"not null;default:0"`
ScanStatus string `gorm:"size:24;not null;default:'not_scanned'"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (SaDocument) TableName() string {
return "sa_documents"
}
type SaDocumentContent struct {
ID uint `gorm:"primaryKey"`
DocumentID uint `gorm:"uniqueIndex;not null"`
Markdown string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaDocumentContent) TableName() string {
return "sa_document_contents"
}
type SaDocumentBlob struct {
ID uint `gorm:"primaryKey"`
DocumentID uint `gorm:"uniqueIndex;not null"`
StorageKey string `gorm:"size:64;uniqueIndex;not null"`
RelativePath string `gorm:"not null"`
OriginalName string `gorm:"not null"`
Checksum string `gorm:"size:64;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaDocumentBlob) TableName() string {
return "sa_document_blobs"
}
type SaDocumentShare struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
DocumentID uint `gorm:"index;not null"`
DocumentIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"index;not null"`
RevokedAt *time.Time `gorm:"index"`
CreatedAt time.Time
}
func (SaDocumentShare) TableName() string {
return "sa_document_shares"
}

View File

@@ -75,7 +75,7 @@ func (m *SaTask) BeforeCreate(tx *gorm.DB) error {
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
}
func (m *SaNote) BeforeCreate(tx *gorm.DB) error {
func (m *SaDocument) BeforeCreate(tx *gorm.DB) error {
if err := ensureIdentity(&m.Identity); err != nil {
return err
}
@@ -85,20 +85,20 @@ func (m *SaNote) BeforeCreate(tx *gorm.DB) error {
if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
if err := resolveOptionalIdentity(tx, &SaDocument{}, m.ParentID, &m.ParentIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
}
func (m *SaSource) BeforeCreate(tx *gorm.DB) error {
func (m *SaDocumentShare) BeforeCreate(tx *gorm.DB) error {
if err := ensureIdentity(&m.Identity); err != nil {
return err
}
if err := resolveIdentity(tx, &SaProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
if err := resolveIdentity(tx, &SaDocument{}, m.DocumentID, &m.DocumentIdentity); err != nil {
return err
}
if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
return nil
}
func (m *SaAISession) BeforeCreate(tx *gorm.DB) error {
@@ -233,10 +233,8 @@ func resolveEntityIdentity(tx *gorm.DB, entityType string, entityID uint, identi
return resolveIdentity(tx, &SaInboxItem{}, entityID, identity)
case "task":
return resolveIdentity(tx, &SaTask{}, entityID, identity)
case "note":
return resolveIdentity(tx, &SaNote{}, entityID, identity)
case "source":
return resolveIdentity(tx, &SaSource{}, entityID, identity)
case "document":
return resolveIdentity(tx, &SaDocument{}, entityID, identity)
case "ai_session":
return resolveIdentity(tx, &SaAISession{}, entityID, identity)
case "tag":

View File

@@ -31,6 +31,7 @@ func runVersionedMigrations(database *gorm.DB) error {
migrations := []versionedMigration{
{version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers},
{version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags},
{version: 3, name: "replace_notes_sources_with_documents", run: replaceNotesSourcesWithDocuments},
}
for _, migration := range migrations {
// Connection callbacks can provide an initialized Gorm session. Start each
@@ -63,6 +64,28 @@ func runVersionedMigrations(database *gorm.DB) error {
return nil
}
func replaceNotesSourcesWithDocuments(tx *gorm.DB) (map[string]int, error) {
deletedShares := int64(0)
if tx.Migrator().HasTable(&SaTaskShare{}) {
result := tx.Where("object_type IN ?", []string{"note", "source"}).Delete(&SaTaskShare{})
if result.Error != nil {
return nil, result.Error
}
deletedShares = result.RowsAffected
}
dropped := 0
for _, table := range []string{"sa_notes", "sa_sources"} {
if !tx.Migrator().HasTable(table) {
continue
}
if err := tx.Migrator().DropTable(table); err != nil {
return nil, err
}
dropped++
}
return map[string]int{"dropped_tables": dropped, "deleted_task_shares": int(deletedShares)}, nil
}
func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) {
var projects []SaProject
if err := tx.Model(&SaProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil {

View File

@@ -63,14 +63,14 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te
var migrationCount int64
require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
var auditDetails []string
require.NoError(t, database.Table("sa_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error)
require.Contains(t, auditDetails[0], `"updated":3`)
require.Contains(t, auditDetails[1], `"deduplicated":1`)
require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again")
require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
require.Error(t, database.Exec(`INSERT INTO sa_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error)
require.Error(t, database.Exec(`INSERT INTO sa_tags (project_id, name) VALUES (1, 'UI')`).Error)

View File

@@ -36,8 +36,10 @@ func AutoMigrate(database *gorm.DB) error {
&SaInboxItem{},
&SaInboxSuggestion{},
&SaTask{},
&SaNote{},
&SaSource{},
&SaDocument{},
&SaDocumentContent{},
&SaDocumentBlob{},
&SaDocumentShare{},
&SaAIExpertCategory{},
&SaAIExpertItem{},
&SaAISession{},

View File

@@ -1,22 +0,0 @@
package models
import "time"
type SaNote struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`
Markdown string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaNote) TableName() string {
return "sa_notes"
}

View File

@@ -1,25 +0,0 @@
package models
import "time"
type SaSource struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Kind string `gorm:"not null"`
Title string `gorm:"not null"`
URL string
FilePath string
ContentText string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaSource) TableName() string {
return "sa_sources"
}