From 3dc5044c99e32c60ab1974e1dfa542b0596bc538 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Tue, 16 Jun 2026 15:25:33 +0300 Subject: [PATCH] feat: support office document nodes --- cmd/server/main.go | 2 + internal/handler/node.go | 66 +++++++++++++++++++ internal/model/model.go | 8 +++ internal/repository/node.go | 43 ++++++++++++ migrations/004_office_document_nodes.down.sql | 7 ++ migrations/004_office_document_nodes.up.sql | 7 ++ 6 files changed, 133 insertions(+) create mode 100644 migrations/004_office_document_nodes.down.sql create mode 100644 migrations/004_office_document_nodes.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 576a031..35ef0c9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -72,6 +72,8 @@ func main() { r.Get("/nodes", nodeH.List) r.Post("/folders", nodeH.CreateFolder) r.Post("/files", nodeH.UploadFile) + r.Get("/office-documents/links", nodeH.ListOfficeLinks) + r.Post("/office-documents", nodeH.CreateOfficeDocument) r.Route("/nodes/{id}", func(r chi.Router) { r.Get("/", nodeH.Get) r.Patch("/", nodeH.Update) diff --git a/internal/handler/node.go b/internal/handler/node.go index 1d42ab0..ddccea8 100644 --- a/internal/handler/node.go +++ b/internal/handler/node.go @@ -73,6 +73,63 @@ func (h *NodeHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, node) } +func (h *NodeHandler) ListOfficeLinks(w http.ResponseWriter, r *http.Request) { + userID := commonmw.GetUserID(r.Context()) + links, err := h.repo.ListOfficeExternalURLs(r.Context(), userID, subordinates(r)) + if err != nil { + writeInternalError(w, r, err, "failed to list office links") + return + } + writeJSON(w, http.StatusOK, links) +} + +func (h *NodeHandler) CreateOfficeDocument(w http.ResponseWriter, r *http.Request) { + var req model.CreateOfficeDocumentRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + req.Title = strings.TrimSpace(req.Title) + req.OfficeID = strings.TrimSpace(req.OfficeID) + req.OfficeFormat = strings.Trim(strings.ToLower(req.OfficeFormat), ". ") + if req.Title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if req.OfficeID == "" { + writeError(w, http.StatusBadRequest, "office_id is required") + return + } + if !allowedOfficeFormat(req.OfficeFormat) { + writeError(w, http.StatusBadRequest, "office_format is not allowed") + return + } + userID := commonmw.GetUserID(r.Context()) + if !h.requireWritableParent(w, r, userID, req.ParentID) { + return + } + externalURL := "/office/" + req.OfficeID + originalFilename := req.Title + "." + req.OfficeFormat + mimeType := req.OfficeFormat + node, err := h.repo.CreateOfficeDocument(r.Context(), &model.Node{ + ParentID: req.ParentID, + Title: req.Title, + OwnerUserID: userID, + OriginalFilename: &originalFilename, + MimeType: &mimeType, + Extension: &req.OfficeFormat, + SizeBytes: req.SizeBytes, + OfficeFormat: &req.OfficeFormat, + ExternalURL: &externalURL, + }) + if err != nil { + writeInternalError(w, r, err, "failed to create office document") + return + } + h.repo.Audit(r.Context(), userID, "files.office_document_create", "files_node", node.ID, "{}") + writeJSON(w, http.StatusCreated, node) +} + func (h *NodeHandler) UploadFile(w http.ResponseWriter, r *http.Request) { if !h.store.Configured() { writeError(w, http.StatusServiceUnavailable, "storage not configured") @@ -376,6 +433,15 @@ func emptyToNil(v string) *string { return &v } +func allowedOfficeFormat(format string) bool { + switch format { + case "doc", "docx", "odt", "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "odp": + return true + default: + return false + } +} + func subordinates(r *http.Request) []string { ids := csvHeader(r, "X-User-Subordinates") if len(ids) == 0 { diff --git a/internal/model/model.go b/internal/model/model.go index 0d16b3a..5158bc7 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -54,6 +54,14 @@ type CreateFolderRequest struct { Title string `json:"title"` } +type CreateOfficeDocumentRequest struct { + ParentID *string `json:"parent_id"` + Title string `json:"title"` + OfficeID string `json:"office_id"` + OfficeFormat string `json:"office_format"` + SizeBytes int64 `json:"size_bytes"` +} + type UpdateNodeRequest struct { ParentID *string `json:"parent_id"` Title *string `json:"title"` diff --git a/internal/repository/node.go b/internal/repository/node.go index 636ba69..fb759ab 100644 --- a/internal/repository/node.go +++ b/internal/repository/node.go @@ -127,6 +127,49 @@ func (r *NodeRepository) CreateFile(ctx context.Context, n *model.Node) (*model. `, n.ParentID, n.Title, n.OwnerUserID, n.StorageKey, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes).Scan) } +func (r *NodeRepository) CreateOfficeDocument(ctx context.Context, n *model.Node) (*model.Node, error) { + return scanNode(r.pool.QueryRow(ctx, ` + INSERT INTO files_nodes + (parent_id, node_type, title, owner_user_id, created_by, original_filename, + mime_type, extension, size_bytes, office_format, external_url) + VALUES ($1, 'office_document', $2, $3, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id, + created_by, updated_by, storage_key, original_filename, mime_type, + extension, size_bytes, office_format, external_url, version, + 'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at + `, n.ParentID, n.Title, n.OwnerUserID, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes, n.OfficeFormat, n.ExternalURL).Scan) +} + +func (r *NodeRepository) ListOfficeExternalURLs(ctx context.Context, userID string, subordinateIDs []string) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT DISTINCT n.external_url + FROM files_nodes n + WHERE n.deleted_at IS NULL + AND n.node_type = 'office_document' + AND n.external_url IS NOT NULL + AND ( + n.owner_user_id = $1 + OR has_node_access(n.id, $1) + OR n.owner_user_id::text = ANY($2::text[]) + ) + ORDER BY n.external_url + `, userID, subordinateIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0) + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + return nil, err + } + out = append(out, url) + } + return out, rows.Err() +} + func (r *NodeRepository) Update(ctx context.Context, id, actorID string, req model.UpdateNodeRequest) (*model.Node, error) { return scanNode(r.pool.QueryRow(ctx, ` UPDATE files_nodes diff --git a/migrations/004_office_document_nodes.down.sql b/migrations/004_office_document_nodes.down.sql new file mode 100644 index 0000000..1686895 --- /dev/null +++ b/migrations/004_office_document_nodes.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE files_nodes + DROP CONSTRAINT IF EXISTS files_nodes_file_storage_check; + +ALTER TABLE files_nodes + ADD CONSTRAINT files_nodes_file_storage_check CHECK ( + node_type IN ('folder', 'google_sheet') OR storage_key IS NOT NULL + ); diff --git a/migrations/004_office_document_nodes.up.sql b/migrations/004_office_document_nodes.up.sql new file mode 100644 index 0000000..e86e32b --- /dev/null +++ b/migrations/004_office_document_nodes.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE files_nodes + DROP CONSTRAINT IF EXISTS files_nodes_file_storage_check; + +ALTER TABLE files_nodes + ADD CONSTRAINT files_nodes_file_storage_check CHECK ( + node_type IN ('folder', 'google_sheet', 'office_document') OR storage_key IS NOT NULL + );