package workspace import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" dbpostgres "excalidraw-complete/internal/postgres" "fmt" "net/mail" "regexp" "strings" "time" "github.com/oklog/ulid/v2" "golang.org/x/crypto/bcrypt" ) var ( ErrConflict = errors.New("resource already exists") ErrForbidden = errors.New("access denied") ErrNotFound = errors.New("resource not found") ) type Store struct { db *dbpostgres.DB } type CreateDrawingRequest struct { TeamID *string `json:"team_id"` FolderID *string `json:"folder_id"` ProjectID *string `json:"project_id"` Title string `json:"title"` Description *string `json:"description"` Visibility string `json:"visibility"` Snapshot json.RawMessage `json:"snapshot"` } type UpdateDrawingRequest struct { FolderID *string `json:"folder_id"` ProjectID *string `json:"project_id"` Title *string `json:"title"` Description *string `json:"description"` Visibility *string `json:"visibility"` } type CreateRevisionRequest struct { Snapshot json.RawMessage `json:"snapshot"` ChangeSummary *string `json:"change_summary"` } type CreateFolderRequest struct { TeamID string `json:"team_id"` ProjectID *string `json:"project_id"` ParentFolderID *string `json:"parent_folder_id"` Name string `json:"name"` Visibility string `json:"visibility"` } type UpdateFolderRequest struct { Name *string `json:"name"` Visibility *string `json:"visibility"` } type CreateProjectRequest struct { TeamID string `json:"team_id"` Name string `json:"name"` Description *string `json:"description"` } func NewStore(databaseURL string) (*Store, error) { db, err := dbpostgres.Open(databaseURL) if err != nil { return nil, err } store := &Store{db: db} if err := dbpostgres.Migrate(context.Background(), db.DB); err != nil { db.Close() return nil, err } if err := store.seedTemplates(context.Background()); err != nil { db.Close() return nil, err } return store, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } func (s *Store) seedTemplates(ctx context.Context) error { var count int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspace_templates WHERE scope = 'system'`).Scan(&count); err != nil { return err } if count > 0 { return nil } now := time.Now().UTC() templates := []Template{ {ID: newID(), Scope: "system", Type: "empty", Name: "Empty Canvas", Description: ptr("Start from a clean workspace."), SnapshotPath: "system/templates/empty.json", MetadataJSON: map[string]any{"category": "starter"}, CreatedBy: "system", CreatedAt: now, UpdatedAt: now}, {ID: newID(), Scope: "system", Type: "kanban", Name: "Kanban Board", Description: ptr("Plan work across simple status lanes."), SnapshotPath: "system/templates/kanban.json", MetadataJSON: map[string]any{"category": "planning"}, CreatedBy: "system", CreatedAt: now, UpdatedAt: now}, {ID: newID(), Scope: "system", Type: "flowchart", Name: "Flowchart", Description: ptr("Map decisions and process steps."), SnapshotPath: "system/templates/flowchart.json", MetadataJSON: map[string]any{"category": "diagram"}, CreatedBy: "system", CreatedAt: now, UpdatedAt: now}, {ID: newID(), Scope: "system", Type: "meeting-notes", Name: "Meeting Notes", Description: ptr("Capture decisions, actions, and follow-ups."), SnapshotPath: "system/templates/meeting-notes.json", MetadataJSON: map[string]any{"category": "meeting"}, CreatedBy: "system", CreatedAt: now, UpdatedAt: now}, } for _, template := range templates { metadata, err := json.Marshal(template.MetadataJSON) if err != nil { return err } _, err = s.db.ExecContext(ctx, `INSERT INTO workspace_templates (id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, template.ID, template.TeamID, template.Scope, template.Type, template.Name, template.Description, template.SnapshotPath, string(metadata), template.CreatedBy, template.CreatedAt, template.UpdatedAt, ) if err != nil { return err } } return nil } func (s *Store) UserExists(ctx context.Context) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspace_users`).Scan(&count) if err != nil { return false, err } return count > 0, nil } func (s *Store) CreateUserWithPassword(ctx context.Context, name, email, password string) (*User, *Session, string, error) { name = strings.TrimSpace(name) email, err := normalizeEmail(email) if err != nil { return nil, nil, "", err } if len(name) < 1 || len(name) > 120 { return nil, nil, "", fmt.Errorf("name must be between 1 and 120 characters") } if len(password) < 8 || len(password) > 128 { return nil, nil, "", fmt.Errorf("password must be between 8 and 128 characters") } hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return nil, nil, "", err } now := time.Now().UTC() user := &User{ ID: newID(), Name: name, Username: slugify(strings.TrimSuffix(email, email[strings.LastIndex(email, "@"):])), Email: email, Locale: "en", Timezone: "UTC", CreatedAt: now, UpdatedAt: now, } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, nil, "", err } defer tx.Rollback() user.Username = uniqueUsername(ctx, tx, user.Username) _, err = tx.ExecContext(ctx, `INSERT INTO workspace_users (id, name, username, email, password_hash, avatar_url, locale, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, user.ID, user.Name, user.Username, user.Email, string(hash), user.AvatarURL, user.Locale, user.Timezone, user.CreatedAt, user.UpdatedAt, ) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return nil, nil, "", ErrConflict } return nil, nil, "", err } team, err := createTeamTx(ctx, tx, user.ID, name+"'s Workspace", "") if err != nil { return nil, nil, "", err } if err := insertActivityTx(ctx, tx, &user.ID, &team.ID, "team", team.ID, "member_joined", map[string]any{"role": "owner"}); err != nil { return nil, nil, "", err } session, token, err := createSessionTx(ctx, tx, user.ID) if err != nil { return nil, nil, "", err } if err := tx.Commit(); err != nil { return nil, nil, "", err } return user, session, token, nil } func (s *Store) CreateTeamUser(ctx context.Context, teamID string, name, email, password, role string) (*User, error) { name = strings.TrimSpace(name) email, err := normalizeEmail(email) if err != nil { return nil, err } if len(name) < 1 || len(name) > 120 { return nil, fmt.Errorf("name must be between 1 and 120 characters") } if len(password) < 8 || len(password) > 128 { return nil, fmt.Errorf("password must be between 8 and 128 characters") } if role != "owner" && role != "admin" && role != "editor" && role != "viewer" { return nil, fmt.Errorf("invalid role") } hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return nil, err } now := time.Now().UTC() user := &User{ ID: newID(), Name: name, Username: slugify(strings.TrimSuffix(email, email[strings.LastIndex(email, "@"):])), Email: email, Locale: "en", Timezone: "UTC", CreatedAt: now, UpdatedAt: now, } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() user.Username = uniqueUsername(ctx, tx, user.Username) _, err = tx.ExecContext(ctx, `INSERT INTO workspace_users (id, name, username, email, password_hash, avatar_url, locale, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, user.ID, user.Name, user.Username, user.Email, string(hash), user.AvatarURL, user.Locale, user.Timezone, user.CreatedAt, user.UpdatedAt, ) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return nil, ErrConflict } return nil, err } _, err = tx.ExecContext(ctx, `INSERT INTO workspace_team_memberships (id, team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?, ?)`, newID(), teamID, user.ID, role, now) if err != nil { return nil, err } if err := insertActivityTx(ctx, tx, &user.ID, &teamID, "team", teamID, "member_joined", map[string]any{"role": role}); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return user, nil } func (s *Store) AuthenticatePassword(ctx context.Context, email, password string) (*User, *Session, string, error) { email, err := normalizeEmail(email) if err != nil { return nil, nil, "", err } row := s.db.QueryRowContext(ctx, `SELECT id, name, username, email, password_hash, avatar_url, locale, timezone, created_at, updated_at FROM workspace_users WHERE email = ?`, email) user, hash, err := scanUserWithHash(row) if err != nil { return nil, nil, "", ErrForbidden } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return nil, nil, "", ErrForbidden } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, nil, "", err } defer tx.Rollback() session, token, err := createSessionTx(ctx, tx, user.ID) if err != nil { return nil, nil, "", err } if err := insertActivityTx(ctx, tx, &user.ID, nil, "user", user.ID, "login_success", map[string]any{}); err != nil { return nil, nil, "", err } if err := tx.Commit(); err != nil { return nil, nil, "", err } return user, session, token, nil } func (s *Store) UserBySessionToken(ctx context.Context, token string) (*User, *Session, error) { hash := hashToken(token) row := s.db.QueryRowContext(ctx, `SELECT u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at, s.id, s.user_id, s.expires_at, s.created_at FROM workspace_sessions s JOIN workspace_users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?`, hash, time.Now().UTC()) var user User var session Session if err := row.Scan(&user.ID, &user.Name, &user.Username, &user.Email, &user.AvatarURL, &user.Locale, &user.Timezone, &user.CreatedAt, &user.UpdatedAt, &session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt); err != nil { return nil, nil, err } return &user, &session, nil } func (s *Store) DeleteSession(ctx context.Context, token string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM workspace_sessions WHERE token_hash = ?`, hashToken(token)) return err } func (s *Store) ListTeamsForUser(ctx context.Context, userID string) ([]Team, error) { rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.name, t.slug, t.owner_user_id, t.plan_type, t.created_at, t.updated_at FROM workspace_teams t JOIN workspace_team_memberships m ON m.team_id = t.id WHERE m.user_id = ? ORDER BY t.created_at ASC`, userID) if err != nil { return nil, err } defer rows.Close() var teams []Team for rows.Next() { var team Team if err := rows.Scan(&team.ID, &team.Name, &team.Slug, &team.OwnerUserID, &team.PlanType, &team.CreatedAt, &team.UpdatedAt); err != nil { return nil, err } teams = append(teams, team) } if teams == nil { teams = []Team{} } return teams, rows.Err() } func (s *Store) CreateTeam(ctx context.Context, ownerUserID, name, slug string) (*Team, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() team, err := createTeamTx(ctx, tx, ownerUserID, name, slug) if err != nil { return nil, err } if err := insertActivityTx(ctx, tx, &ownerUserID, &team.ID, "team", team.ID, "member_joined", map[string]any{"role": "owner"}); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return team, nil } func (s *Store) UpdateTeam(ctx context.Context, userID, teamID string, name, slug *string) (*Team, error) { var role string err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE user_id = ? AND team_id = ?`, userID, teamID).Scan(&role) if errors.Is(err, sql.ErrNoRows) { return nil, ErrForbidden } if err != nil { return nil, err } if role != "owner" && role != "admin" { return nil, ErrForbidden } updates := []string{} args := []any{} if name != nil { n := strings.TrimSpace(*name) if n == "" || len(n) > 120 { return nil, fmt.Errorf("team name must be between 1 and 120 characters") } updates = append(updates, "name = ?") args = append(args, n) } if slug != nil { s := slugify(*slug) if s == "" { return nil, fmt.Errorf("team slug must not be empty") } updates = append(updates, "slug = ?") args = append(args, s) } if len(updates) == 0 { return s.GetTeam(ctx, teamID) } updates = append(updates, "updated_at = ?") args = append(args, time.Now().UTC()) args = append(args, teamID) query := "UPDATE workspace_teams SET " + strings.Join(updates, ", ") + " WHERE id = ?" if _, err := s.db.ExecContext(ctx, query, args...); err != nil { return nil, err } return s.GetTeam(ctx, teamID) } func (s *Store) GetTeam(ctx context.Context, teamID string) (*Team, error) { row := s.db.QueryRowContext(ctx, `SELECT id, name, slug, owner_user_id, plan_type, created_at, updated_at FROM workspace_teams WHERE id = ?`, teamID) var t Team if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.OwnerUserID, &t.PlanType, &t.CreatedAt, &t.UpdatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } return &t, nil } func (s *Store) UserCanAccessTeam(ctx context.Context, userID, teamID string) (bool, error) { var found int err := s.db.QueryRowContext(ctx, `SELECT 1 FROM workspace_team_memberships WHERE user_id = ? AND team_id = ?`, userID, teamID).Scan(&found) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } func (s *Store) ListTeamMembers(ctx context.Context, teamID string) ([]TeamMembership, error) { rows, err := s.db.QueryContext(ctx, `SELECT m.id, m.team_id, m.user_id, m.role, m.joined_at, u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at FROM workspace_team_memberships m JOIN workspace_users u ON u.id = m.user_id WHERE m.team_id = ? ORDER BY m.joined_at ASC`, teamID) if err != nil { return nil, err } defer rows.Close() members := []TeamMembership{} for rows.Next() { var member TeamMembership var user User if err := rows.Scan(&member.ID, &member.TeamID, &member.UserID, &member.Role, &member.JoinedAt, &user.ID, &user.Name, &user.Username, &user.Email, &user.AvatarURL, &user.Locale, &user.Timezone, &user.CreatedAt, &user.UpdatedAt); err != nil { return nil, err } member.User = &user members = append(members, member) } return members, rows.Err() } func (s *Store) ListDrawings(ctx context.Context, userID, teamID string) ([]Drawing, error) { if teamID != "" { if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } return s.listDrawingsByQuery(ctx, `d.team_id = ?`, teamID) } return s.listDrawingsByQuery(ctx, `d.team_id IN (SELECT team_id FROM workspace_team_memberships WHERE user_id = ?)`, userID) } func (s *Store) listDrawingsByQuery(ctx context.Context, where string, arg string) ([]Drawing, error) { rows, err := s.db.QueryContext(ctx, `SELECT d.id, d.team_id, d.folder_id, d.project_id, d.slug, d.title, d.description, d.owner_user_id, d.latest_revision_id, d.visibility, d.is_archived, d.thumbnail_asset_id, d.created_at, d.updated_at, d.deleted_at, u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at FROM workspace_drawings d JOIN workspace_users u ON u.id = d.owner_user_id WHERE d.deleted_at IS NULL AND `+where+` ORDER BY d.updated_at DESC`, arg) if err != nil { return nil, err } defer rows.Close() drawings := []Drawing{} for rows.Next() { drawing, err := scanDrawing(rows) if err != nil { return nil, err } drawings = append(drawings, *drawing) } return drawings, rows.Err() } // SearchDrawings performs a fulltext-like search over drawing titles and descriptions // for drawings the user can access via their team memberships. func (s *Store) SearchDrawings(ctx context.Context, userID string, q string) ([]Drawing, error) { searchPattern := "%" + strings.TrimSpace(q) + "%" where := `d.team_id IN (SELECT team_id FROM workspace_team_memberships WHERE user_id = ?)` rows, err := s.db.QueryContext(ctx, `SELECT d.id, d.team_id, d.folder_id, d.project_id, d.slug, d.title, d.description, d.owner_user_id, d.latest_revision_id, d.visibility, d.is_archived, d.thumbnail_asset_id, d.created_at, d.updated_at, d.deleted_at, u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at FROM workspace_drawings d JOIN workspace_users u ON u.id = d.owner_user_id WHERE d.deleted_at IS NULL AND `+where+` AND (d.title LIKE ? OR d.description LIKE ?) ORDER BY d.updated_at DESC`, userID, searchPattern, searchPattern) if err != nil { return nil, err } defer rows.Close() drawings := []Drawing{} for rows.Next() { drawing, err := scanDrawing(rows) if err != nil { return nil, err } drawings = append(drawings, *drawing) } return drawings, rows.Err() } func (s *Store) CreateDrawing(ctx context.Context, userID string, req CreateDrawingRequest) (*Drawing, error) { title := strings.TrimSpace(req.Title) if title == "" { title = "Untitled drawing" } if len(title) > 160 { return nil, fmt.Errorf("title must be at most 160 characters") } teamID := deref(req.TeamID) if teamID == "" { var err error teamID, err = s.defaultTeamID(ctx, userID) if err != nil { return nil, err } } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } visibility := req.Visibility if visibility == "" { visibility = "team" } if !validDrawingVisibility(visibility) { return nil, fmt.Errorf("invalid drawing visibility") } now := time.Now().UTC() drawing := &Drawing{ ID: newID(), TeamID: teamID, FolderID: req.FolderID, ProjectID: req.ProjectID, Title: title, Description: req.Description, OwnerUserID: userID, Visibility: visibility, CreatedAt: now, UpdatedAt: now, } slug := slugify(title) drawing.Slug = &slug tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `INSERT INTO workspace_drawings (id, team_id, folder_id, project_id, slug, title, description, owner_user_id, latest_revision_id, visibility, is_archived, thumbnail_asset_id, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, drawing.ID, drawing.TeamID, drawing.FolderID, drawing.ProjectID, drawing.Slug, drawing.Title, drawing.Description, drawing.OwnerUserID, drawing.LatestRevisionID, drawing.Visibility, drawing.IsArchived, drawing.ThumbnailAssetID, drawing.CreatedAt, drawing.UpdatedAt, drawing.DeletedAt, ) if err != nil { return nil, err } if len(req.Snapshot) > 0 { rev, err := createRevisionTx(ctx, tx, userID, drawing.ID, req.Snapshot, nil) if err != nil { return nil, err } drawing.LatestRevisionID = &rev.ID } if err := insertActivityTx(ctx, tx, &userID, &drawing.TeamID, "drawing", drawing.ID, "drawing_created", map[string]any{"title": drawing.Title}); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return s.GetDrawing(ctx, userID, drawing.ID) } func (s *Store) GetDrawing(ctx context.Context, userID, drawingID string) (*Drawing, error) { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "view"); err != nil { return nil, err } row := s.db.QueryRowContext(ctx, `SELECT d.id, d.team_id, d.folder_id, d.project_id, d.slug, d.title, d.description, d.owner_user_id, d.latest_revision_id, d.visibility, d.is_archived, d.thumbnail_asset_id, d.created_at, d.updated_at, d.deleted_at, u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at FROM workspace_drawings d JOIN workspace_users u ON u.id = d.owner_user_id WHERE d.id = ?`, drawingID) return scanDrawing(row) } func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req UpdateDrawingRequest) (*Drawing, error) { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil { return nil, err } current, err := s.GetDrawing(ctx, userID, drawingID) if err != nil { return nil, err } if req.Title != nil { title := strings.TrimSpace(*req.Title) if title == "" || len(title) > 160 { return nil, fmt.Errorf("title must be between 1 and 160 characters") } current.Title = title slug := slugify(title) current.Slug = &slug } if req.Description != nil { current.Description = req.Description } if req.Visibility != nil { if !validDrawingVisibility(*req.Visibility) { return nil, fmt.Errorf("invalid drawing visibility") } current.Visibility = *req.Visibility } if req.FolderID != nil { current.FolderID = req.FolderID } if req.ProjectID != nil { current.ProjectID = req.ProjectID } now := time.Now().UTC() _, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings SET folder_id = ?, project_id = ?, slug = ?, title = ?, description = ?, visibility = ?, updated_at = ? WHERE id = ?`, current.FolderID, current.ProjectID, current.Slug, current.Title, current.Description, current.Visibility, now, current.ID, ) if err != nil { return nil, err } _ = s.insertActivity(ctx, &userID, ¤t.TeamID, "drawing", current.ID, "drawing_updated", map[string]any{"title": current.Title}) return s.GetDrawing(ctx, userID, drawingID) } func (s *Store) AutosaveDrawing(ctx context.Context, userID, drawingID string, snapshot json.RawMessage) error { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil { return err } if len(snapshot) == 0 || !json.Valid(snapshot) { return fmt.Errorf("snapshot must be valid JSON") } now := time.Now().UTC() _, err := s.db.ExecContext(ctx, `UPDATE workspace_drawings SET updated_at = ? WHERE id = ?`, now, drawingID) if err != nil { return err } // Upsert the latest revision snapshot directly without creating a new revision entry var existingRevID string var revNumber int err = s.db.QueryRowContext(ctx, `SELECT id, revision_number FROM workspace_drawing_revisions WHERE drawing_id = ? ORDER BY revision_number DESC LIMIT 1`, drawingID).Scan(&existingRevID, &revNumber) if errors.Is(err, sql.ErrNoRows) { // Create initial revision if none exists revID := newID() _, err = s.db.ExecContext(ctx, `INSERT INTO workspace_drawing_revisions (id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, revID, drawingID, 1, fmt.Sprintf("teams/drawings/%s/revisions/1.json", drawingID), int64(len(snapshot)), func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(), []byte(snapshot), userID, now, "Auto-save", ) if err != nil { return err } _, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings SET latest_revision_id = ?, updated_at = ? WHERE id = ?`, revID, now, drawingID) return err } if err != nil { return err } // Update existing latest revision snapshot _, err = s.db.ExecContext(ctx, `UPDATE workspace_drawing_revisions SET snapshot_json = ?, snapshot_size = ?, content_hash = ?, updated_at = ? WHERE id = ?`, []byte(snapshot), int64(len(snapshot)), func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(), now, existingRevID) return err } func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil { return err } now := time.Now().UTC() res, err := s.db.ExecContext(ctx, `UPDATE workspace_drawings SET is_archived = true, deleted_at = ?, updated_at = ? WHERE id = ?`, now, now, drawingID) if err != nil { return err } count, _ := res.RowsAffected() if count == 0 { return ErrNotFound } return nil } func (s *Store) ListRevisions(ctx context.Context, userID, drawingID string) ([]DrawingRevision, error) { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "view"); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, `SELECT id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary FROM workspace_drawing_revisions WHERE drawing_id = ? ORDER BY revision_number DESC`, drawingID) if err != nil { return nil, err } defer rows.Close() revisions := []DrawingRevision{} for rows.Next() { var rev DrawingRevision if err := rows.Scan(&rev.ID, &rev.DrawingID, &rev.RevisionNumber, &rev.SnapshotPath, &rev.SnapshotSize, &rev.ContentHash, &rev.Snapshot, &rev.CreatedBy, &rev.CreatedAt, &rev.ChangeSummary); err != nil { return nil, err } revisions = append(revisions, rev) } return revisions, rows.Err() } func (s *Store) CreateRevision(ctx context.Context, userID, drawingID string, req CreateRevisionRequest) (*DrawingRevision, error) { if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil { return nil, err } if len(req.Snapshot) == 0 || !json.Valid(req.Snapshot) { return nil, fmt.Errorf("snapshot must be valid JSON") } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() rev, err := createRevisionTx(ctx, tx, userID, drawingID, req.Snapshot, req.ChangeSummary) if err != nil { return nil, err } var teamID string if err := tx.QueryRowContext(ctx, `SELECT team_id FROM workspace_drawings WHERE id = ?`, drawingID).Scan(&teamID); err == nil { if err := insertActivityTx(ctx, tx, &userID, &teamID, "drawing", drawingID, "revision_created", map[string]any{"revision_number": rev.RevisionNumber}); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return rev, nil } func (s *Store) ListTemplates(ctx context.Context, userID, teamID string) ([]Template, error) { var rows *sql.Rows var err error if teamID != "" { if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } rows, err = s.db.QueryContext(ctx, `SELECT id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at FROM workspace_templates WHERE scope = 'system' OR team_id = ? ORDER BY scope, name`, teamID) } else { rows, err = s.db.QueryContext(ctx, `SELECT id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at FROM workspace_templates WHERE scope = 'system' OR team_id IN (SELECT team_id FROM workspace_team_memberships WHERE user_id = ?) ORDER BY scope, name`, userID) } if err != nil { return nil, err } defer rows.Close() templates := []Template{} for rows.Next() { var template Template var metadata string if err := rows.Scan(&template.ID, &template.TeamID, &template.Scope, &template.Type, &template.Name, &template.Description, &template.SnapshotPath, &metadata, &template.CreatedBy, &template.CreatedAt, &template.UpdatedAt); err != nil { return nil, err } _ = json.Unmarshal([]byte(metadata), &template.MetadataJSON) if template.MetadataJSON == nil { template.MetadataJSON = map[string]any{} } templates = append(templates, template) } return templates, rows.Err() } func (s *Store) CreateTemplate(ctx context.Context, userID string, req CreateTemplateRequest) (*Template, error) { teamID := strings.TrimSpace(req.TeamID) if teamID == "" { teamID, _ = s.defaultTeamID(ctx, userID) } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } name := strings.TrimSpace(req.Name) if name == "" || len(name) > 120 { return nil, fmt.Errorf("template name must be between 1 and 120 characters") } if len(req.Snapshot) == 0 || !json.Valid(req.Snapshot) { return nil, fmt.Errorf("snapshot must be valid JSON") } now := time.Now().UTC() template := &Template{ ID: newID(), TeamID: &teamID, Scope: "team", Type: "custom", Name: name, Description: ptr(strings.TrimSpace(req.Description)), SnapshotPath: fmt.Sprintf("teams/%s/templates/%s.json", teamID, newID()), MetadataJSON: req.Metadata, CreatedBy: userID, CreatedAt: now, UpdatedAt: now, } metadata, _ := json.Marshal(template.MetadataJSON) _, err := s.db.ExecContext(ctx, `INSERT INTO workspace_templates (id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, template.ID, template.TeamID, template.Scope, template.Type, template.Name, template.Description, template.SnapshotPath, string(metadata), template.CreatedBy, template.CreatedAt, template.UpdatedAt, ) if err != nil { return nil, err } _ = s.insertActivity(ctx, &userID, &teamID, "template", template.ID, "template_created", map[string]any{"name": template.Name}) return template, nil } func (s *Store) DeleteTemplate(ctx context.Context, userID, templateID string) error { var teamID, createdBy string err := s.db.QueryRowContext(ctx, `SELECT team_id, created_by FROM workspace_templates WHERE id = ?`, templateID).Scan(&teamID, &createdBy) if err != nil { if err == sql.ErrNoRows { return ErrNotFound } return err } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return ErrForbidden } // Only creator or team admin can delete if createdBy != userID { // Check if user is admin var role string err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role) if err != nil || (role != "admin" && role != "owner") { return ErrForbidden } } _, err = s.db.ExecContext(ctx, `DELETE FROM workspace_templates WHERE id = ?`, templateID) return err } func (s *Store) ListActivity(ctx context.Context, userID, teamID string, limit int) ([]ActivityEvent, error) { if limit <= 0 || limit > 100 { limit = 50 } where := `a.team_id IN (SELECT team_id FROM workspace_team_memberships WHERE user_id = ?)` arg := userID if teamID != "" { if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } where = `a.team_id = ?` arg = teamID } rows, err := s.db.QueryContext(ctx, `SELECT a.id, a.actor_user_id, a.team_id, a.resource_type, a.resource_id, a.event_type, a.metadata_json, a.created_at, u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at FROM workspace_activity_events a LEFT JOIN workspace_users u ON u.id = a.actor_user_id WHERE `+where+` ORDER BY a.created_at DESC LIMIT ?`, arg, limit) if err != nil { return nil, err } defer rows.Close() events := []ActivityEvent{} for rows.Next() { var event ActivityEvent var metadata string var actor User var actorID sql.NullString var actorName sql.NullString var actorUsername sql.NullString var actorEmail sql.NullString var actorAvatar sql.NullString var actorLocale sql.NullString var actorTimezone sql.NullString var actorCreated time.Time var actorUpdated time.Time if err := rows.Scan(&event.ID, &event.ActorUserID, &event.TeamID, &event.ResourceType, &event.ResourceID, &event.EventType, &metadata, &event.CreatedAt, &actorID, &actorName, &actorUsername, &actorEmail, &actorAvatar, &actorLocale, &actorTimezone, &actorCreated, &actorUpdated); err != nil { return nil, err } _ = json.Unmarshal([]byte(metadata), &event.MetadataJSON) if event.MetadataJSON == nil { event.MetadataJSON = map[string]any{} } if actorID.Valid { actor.ID = actorID.String actor.Name = actorName.String actor.Username = actorUsername.String actor.Email = actorEmail.String if actorAvatar.Valid { actor.AvatarURL = &actorAvatar.String } actor.Locale = actorLocale.String actor.Timezone = actorTimezone.String actor.CreatedAt = actorCreated actor.UpdatedAt = actorUpdated event.Actor = &actor } events = append(events, event) } return events, rows.Err() } func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folder, error) { if teamID == "" { teamID, _ = s.defaultTeamID(ctx, userID) } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID) if err != nil { return nil, err } defer rows.Close() folders := []Folder{} for rows.Next() { var folder Folder if err := rows.Scan(&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt); err != nil { return nil, err } folders = append(folders, folder) } return folders, rows.Err() } func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolderRequest) (*Folder, error) { teamID := strings.TrimSpace(req.TeamID) if teamID == "" { var err error teamID, err = s.defaultTeamID(ctx, userID) if err != nil { return nil, err } } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } name := strings.TrimSpace(req.Name) if name == "" || len(name) > 120 { return nil, fmt.Errorf("folder name must be between 1 and 120 characters") } visibility := req.Visibility if visibility == "" { visibility = "team" } now := time.Now().UTC() folder := &Folder{ ID: newID(), TeamID: teamID, ProjectID: req.ProjectID, ParentFolderID: req.ParentFolderID, Name: name, Slug: slugify(name), PathCache: slugify(name), Visibility: visibility, CreatedBy: userID, CreatedAt: now, UpdatedAt: now, } var maxOrder int s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder) _, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders (id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder, ) if err != nil { return nil, err } return folder, nil } func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) { var teamID string err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } var updates []string var args []any if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" || len(name) > 120 { return nil, fmt.Errorf("folder name must be between 1 and 120 characters") } updates = append(updates, "name = ?") args = append(args, name) updates = append(updates, "slug = ?") args = append(args, slugify(name)) updates = append(updates, "path_cache = ?") args = append(args, slugify(name)) } if req.Visibility != nil { updates = append(updates, "visibility = ?") args = append(args, *req.Visibility) } if len(updates) == 0 { return s.GetFolder(ctx, folderID) } updates = append(updates, "updated_at = ?") args = append(args, time.Now().UTC()) args = append(args, folderID) query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?" _, err = s.db.ExecContext(ctx, query, args...) if err != nil { return nil, err } return s.GetFolder(ctx, folderID) } func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) { var folder Folder err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan( &folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } return &folder, nil } func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error { var teamID string err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } return err } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return ErrForbidden } _, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID) return err } func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error { if len(folderIDs) == 0 { return nil } var teamID string err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } return err } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return ErrForbidden } for i, id := range folderIDs { _, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID) if err != nil { return err } } return nil } func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) { if teamID == "" { teamID, _ = s.defaultTeamID(ctx, userID) } if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok { return nil, ErrForbidden } rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, name, slug, description, created_by, created_at, updated_at FROM workspace_projects WHERE team_id = ? ORDER BY created_at ASC`, teamID) if err != nil { return nil, err } defer rows.Close() projects := []Project{} for rows.Next() { var project Project if err := rows.Scan(&project.ID, &project.TeamID, &project.Name, &project.Slug, &project.Description, &project.CreatedBy, &project.CreatedAt, &project.UpdatedAt); err != nil { return nil, err } projects = append(projects, project) } return projects, rows.Err() } func (s *Store) CreateProject(ctx context.Context, userID string, req CreateProjectRequest) (*Project, error) { if ok, err := s.UserCanAccessTeam(ctx, userID, req.TeamID); err != nil || !ok { return nil, ErrForbidden } name := strings.TrimSpace(req.Name) if name == "" || len(name) > 120 { return nil, fmt.Errorf("project name must be between 1 and 120 characters") } now := time.Now().UTC() project := &Project{ ID: newID(), TeamID: req.TeamID, Name: name, Slug: slugify(name), Description: req.Description, CreatedBy: userID, CreatedAt: now, UpdatedAt: now, } _, err := s.db.ExecContext(ctx, `INSERT INTO workspace_projects (id, team_id, name, slug, description, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, project.ID, project.TeamID, project.Name, project.Slug, project.Description, project.CreatedBy, project.CreatedAt, project.UpdatedAt, ) if err != nil { return nil, err } return project, nil } func (s *Store) defaultTeamID(ctx context.Context, userID string) (string, error) { var teamID string err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_team_memberships WHERE user_id = ? ORDER BY joined_at ASC LIMIT 1`, userID).Scan(&teamID) if err != nil { return "", err } return teamID, nil } func (s *Store) ensureDrawingAccess(ctx context.Context, userID, drawingID, permission string) error { var teamID string var ownerUserID string var visibility string var role sql.NullString err := s.db.QueryRowContext(ctx, `SELECT d.team_id, d.owner_user_id, d.visibility, m.role FROM workspace_drawings d LEFT JOIN workspace_team_memberships m ON m.team_id = d.team_id AND m.user_id = ? WHERE d.id = ? AND d.deleted_at IS NULL`, userID, drawingID).Scan(&teamID, &ownerUserID, &visibility, &role) if errors.Is(err, sql.ErrNoRows) { return ErrForbidden } if err != nil { return err } if ownerUserID == userID { return nil } if ok, err := s.grantAllows(ctx, "drawing", drawingID, userID, teamID, permission); err != nil { return err } else if ok { return nil } if !role.Valid { return ErrForbidden } if visibility == "private" || visibility == "restricted" { return ErrForbidden } if roleAllows(role.String, permission) { return nil } return ErrForbidden } func createTeamTx(ctx context.Context, tx *dbpostgres.Tx, ownerUserID, name, slug string) (*Team, error) { name = strings.TrimSpace(name) if name == "" || len(name) > 120 { return nil, fmt.Errorf("team name must be between 1 and 120 characters") } if slug == "" { slug = slugify(name) } slug = slugify(slug) slug = uniqueTeamSlug(ctx, tx, slug) now := time.Now().UTC() team := &Team{ ID: newID(), Name: name, Slug: slug, OwnerUserID: ownerUserID, PlanType: "free", CreatedAt: now, UpdatedAt: now, } _, err := tx.ExecContext(ctx, `INSERT INTO workspace_teams (id, name, slug, owner_user_id, plan_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, team.ID, team.Name, team.Slug, team.OwnerUserID, team.PlanType, team.CreatedAt, team.UpdatedAt, ) if err != nil { return nil, err } _, err = tx.ExecContext(ctx, `INSERT INTO workspace_team_memberships (id, team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?, ?)`, newID(), team.ID, ownerUserID, "owner", now) if err != nil { return nil, err } return team, nil } func createSessionTx(ctx context.Context, tx *dbpostgres.Tx, userID string) (*Session, string, error) { token, err := randomToken() if err != nil { return nil, "", err } now := time.Now().UTC() session := &Session{ ID: newID(), UserID: userID, ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, } _, err = tx.ExecContext(ctx, `INSERT INTO workspace_sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`, session.ID, session.UserID, hashToken(token), session.ExpiresAt, session.CreatedAt) if err != nil { return nil, "", err } return session, token, nil } func createRevisionTx(ctx context.Context, tx *dbpostgres.Tx, userID, drawingID string, snapshot json.RawMessage, summary *string) (*DrawingRevision, error) { if len(snapshot) == 0 { snapshot = json.RawMessage(`{"type":"excalidraw","elements":[],"appState":{},"files":{}}`) } if !json.Valid(snapshot) { return nil, fmt.Errorf("snapshot must be valid JSON") } var next int if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(revision_number), 0) + 1 FROM workspace_drawing_revisions WHERE drawing_id = ?`, drawingID).Scan(&next); err != nil { return nil, err } sum := sha256.Sum256(snapshot) now := time.Now().UTC() rev := &DrawingRevision{ ID: newID(), DrawingID: drawingID, RevisionNumber: next, SnapshotPath: fmt.Sprintf("teams/drawings/%s/revisions/%d.json", drawingID, next), SnapshotSize: int64(len(snapshot)), ContentHash: hex.EncodeToString(sum[:]), CreatedBy: userID, CreatedAt: now, ChangeSummary: summary, Snapshot: snapshot, } _, err := tx.ExecContext(ctx, `INSERT INTO workspace_drawing_revisions (id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, rev.ID, rev.DrawingID, rev.RevisionNumber, rev.SnapshotPath, rev.SnapshotSize, rev.ContentHash, []byte(snapshot), rev.CreatedBy, rev.CreatedAt, rev.ChangeSummary, ) if err != nil { return nil, err } _, err = tx.ExecContext(ctx, `UPDATE workspace_drawings SET latest_revision_id = ?, updated_at = ? WHERE id = ?`, rev.ID, now, drawingID) if err != nil { return nil, err } return rev, nil } func (s *Store) insertActivity(ctx context.Context, actorUserID, teamID *string, resourceType, resourceID, eventType string, metadata map[string]any) error { return insertActivityExec(ctx, s.db, actorUserID, teamID, resourceType, resourceID, eventType, metadata) } func insertActivityTx(ctx context.Context, tx *dbpostgres.Tx, actorUserID, teamID *string, resourceType, resourceID, eventType string, metadata map[string]any) error { return insertActivityExec(ctx, tx, actorUserID, teamID, resourceType, resourceID, eventType, metadata) } type execer interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } func insertActivityExec(ctx context.Context, exec execer, actorUserID, teamID *string, resourceType, resourceID, eventType string, metadata map[string]any) error { if metadata == nil { metadata = map[string]any{} } raw, err := json.Marshal(metadata) if err != nil { return err } _, err = exec.ExecContext(ctx, `INSERT INTO workspace_activity_events (id, actor_user_id, team_id, resource_type, resource_id, event_type, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, newID(), actorUserID, teamID, resourceType, resourceID, eventType, string(raw), time.Now().UTC(), ) return err } type drawingScanner interface { Scan(dest ...any) error } func scanDrawing(scanner drawingScanner) (*Drawing, error) { var drawing Drawing var owner User if err := scanner.Scan(&drawing.ID, &drawing.TeamID, &drawing.FolderID, &drawing.ProjectID, &drawing.Slug, &drawing.Title, &drawing.Description, &drawing.OwnerUserID, &drawing.LatestRevisionID, &drawing.Visibility, &drawing.IsArchived, &drawing.ThumbnailAssetID, &drawing.CreatedAt, &drawing.UpdatedAt, &drawing.DeletedAt, &owner.ID, &owner.Name, &owner.Username, &owner.Email, &owner.AvatarURL, &owner.Locale, &owner.Timezone, &owner.CreatedAt, &owner.UpdatedAt); err != nil { return nil, err } drawing.Owner = &owner return &drawing, nil } type userHashScanner interface { Scan(dest ...any) error } func scanUserWithHash(scanner userHashScanner) (*User, string, error) { var user User var hash string err := scanner.Scan(&user.ID, &user.Name, &user.Username, &user.Email, &hash, &user.AvatarURL, &user.Locale, &user.Timezone, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, "", err } return &user, hash, nil } func normalizeEmail(value string) (string, error) { email := strings.ToLower(strings.TrimSpace(value)) if len(email) > 254 { return "", fmt.Errorf("email must be at most 254 characters") } if _, err := mail.ParseAddress(email); err != nil { return "", fmt.Errorf("email must be valid") } return email, nil } func uniqueUsername(ctx context.Context, tx *dbpostgres.Tx, base string) string { if base == "" { base = "user" } candidate := base for i := 2; ; i++ { var found int err := tx.QueryRowContext(ctx, `SELECT 1 FROM workspace_users WHERE username = ?`, candidate).Scan(&found) if errors.Is(err, sql.ErrNoRows) { return candidate } candidate = fmt.Sprintf("%s-%d", base, i) } } func uniqueTeamSlug(ctx context.Context, tx *dbpostgres.Tx, base string) string { if base == "" { base = "team" } candidate := base for i := 2; ; i++ { var found int err := tx.QueryRowContext(ctx, `SELECT 1 FROM workspace_teams WHERE slug = ?`, candidate).Scan(&found) if errors.Is(err, sql.ErrNoRows) { return candidate } candidate = fmt.Sprintf("%s-%d", base, i) } } var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) // Notifications func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) { if limit <= 0 || limit > 100 { limit = 50 } rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at FROM workspace_notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit) if err != nil { return nil, err } defer rows.Close() notifications := []Notification{} for rows.Next() { var n Notification var metadata string var resourceType sql.NullString var resourceID sql.NullString if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Description, &resourceType, &resourceID, &n.Read, &metadata, &n.CreatedAt); err != nil { return nil, err } if resourceType.Valid { n.ResourceType = resourceType.String } if resourceID.Valid { n.ResourceID = resourceID.String } _ = json.Unmarshal([]byte(metadata), &n.MetadataJSON) if n.MetadataJSON == nil { n.MetadataJSON = map[string]any{} } notifications = append(notifications, n) } return notifications, rows.Err() } func (s *Store) MarkNotificationRead(ctx context.Context, userID, notificationID string) error { res, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE id = ? AND user_id = ?`, notificationID, userID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error { _, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE user_id = ? AND read = FALSE`, userID) return err } func (s *Store) CreateNotification(ctx context.Context, userID, nType, title, description, resourceType, resourceID string, metadata map[string]any) (*Notification, error) { metadataJSON := []byte("{}") if metadata != nil { b, _ := json.Marshal(metadata) metadataJSON = b } now := time.Now().UTC() n := &Notification{ ID: newID(), UserID: userID, Type: nType, Title: title, Description: description, ResourceType: resourceType, ResourceID: resourceID, Read: false, MetadataJSON: map[string]any{}, CreatedAt: now, } _, err := s.db.ExecContext(ctx, `INSERT INTO workspace_notifications (id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.ID, n.UserID, n.Type, n.Title, n.Description, resourceType, resourceID, false, string(metadataJSON), now) if err != nil { return nil, err } _ = json.Unmarshal(metadataJSON, &n.MetadataJSON) return n, nil } func slugify(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = nonSlugChars.ReplaceAllString(value, "-") value = strings.Trim(value, "-") if value == "" { return "item" } if len(value) > 80 { value = strings.Trim(value[:80], "-") } return value } func validDrawingVisibility(visibility string) bool { switch visibility { case "private", "team", "public", "restricted", "public-link": return true default: return false } } func randomToken() (string, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(raw), nil } func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func newID() string { return ulid.Make().String() } func ptr[T any](value T) *T { return &value } func deref(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) }