Files
Excalidraw/workspace/store.go
T

1206 lines
40 KiB
Go

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 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 = ? AND d.deleted_at IS NULL`, 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, &current.TeamID, "drawing", current.ID, "drawing_updated", map[string]any{"title": current.Title})
return s.GetDrawing(ctx, userID, drawingID)
}
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) 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 path_cache 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) {
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("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: req.TeamID,
ProjectID: req.ProjectID,
ParentFolderID: req.ParentFolderID,
Name: name,
Slug: slugify(name),
PathCache: slugify(name),
Visibility: visibility,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
_, 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 {
return nil, err
}
return folder, 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]+`)
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)
}