diff --git a/scripts/migrate-project-settings/Makefile b/scripts/migrate-project-settings/Makefile new file mode 100644 index 00000000..1cbf8f61 --- /dev/null +++ b/scripts/migrate-project-settings/Makefile @@ -0,0 +1,26 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +.PHONY: help run build clean test + +help: ## Display available commands + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +run: ## Run the migration script (requires PROJECT_UID) + @if [ -z "$(PROJECT_UID)" ]; then \ + echo "Usage: make run PROJECT_UID="; \ + echo "Example: make run PROJECT_UID=7cad5a8d-19d0-41a4-81a6-043453daf9ee"; \ + exit 1; \ + fi + go run main.go $(PROJECT_UID) + +build: ## Build the migration script + go build -o bin/migrate-project-settings main.go + +clean: ## Clean built binaries + rm -rf bin/ + +test: ## Run tests + go test -v ./... + +.DEFAULT_GOAL := help \ No newline at end of file diff --git a/scripts/migrate-project-settings/README.md b/scripts/migrate-project-settings/README.md new file mode 100644 index 00000000..f849a2fe --- /dev/null +++ b/scripts/migrate-project-settings/README.md @@ -0,0 +1,58 @@ +# Project Settings Migration Script + +This script migrates project settings from the old format (where writers and auditors are arrays of strings) to the new format (where they are arrays of UserInfo objects with name, username, email, and avatar fields). + +## Usage + +```bash +cd scripts/migrate-project-settings +go run main.go +``` + +## Environment Variables + +- `NATS_URL`: NATS server URL (defaults to `nats://localhost:4222`) + +## What it does + +1. Connects to NATS and retrieves the project settings from the `project-settings` KV store +2. Checks if the settings are already in the new format +3. If in old format, prompts for user details (name, username, email, avatar) for each writer and auditor +4. Updates the settings in the NATS KV store with the new format +5. Sends an indexer sync message to update the search index + +## Example + +```bash +# Set NATS URL if different from default +export NATS_URL=nats://localhost:4222 + +# Run migration for a specific project +go run main.go 7cad5a8d-19d0-41a4-81a6-043453daf9ee +``` + +The script will prompt you for each user: + +``` +Migrating writer: johndoe +Enter details for user 'johndoe': +Name: John Doe +Username [johndoe]: +Email: john.doe@example.com +Avatar URL (optional): https://example.com/avatar.jpg + +Migrating auditor: janesmith +Enter details for user 'janesmith': +Name: Jane Smith +Username [janesmith]: +Email: jane.smith@example.com +Avatar URL (optional): +``` + +## Notes + +- The script preserves all existing project settings data +- Only writers and auditors fields are migrated from string arrays to UserInfo arrays +- The script updates the `updated_at` timestamp +- An indexer sync message is sent after successful migration +- If the project is already in the new format, the script will exit without making changes \ No newline at end of file diff --git a/scripts/migrate-project-settings/main.go b/scripts/migrate-project-settings/main.go new file mode 100644 index 00000000..3ef481e6 --- /dev/null +++ b/scripts/migrate-project-settings/main.go @@ -0,0 +1,242 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" + natsmsg "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/nats" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// OldProjectSettings represents the old format with string arrays +type OldProjectSettings struct { + UID string `json:"uid"` + MissionStatement string `json:"mission_statement"` + AnnouncementDate *time.Time `json:"announcement_date"` + Auditors []string `json:"auditors"` // Old format: array of strings + Writers []string `json:"writers"` // Old format: array of strings + MeetingCoordinators []string `json:"meeting_coordinators"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + projectUID := os.Args[1] + + // Get NATS URL from environment or use default + natsURL := os.Getenv("NATS_URL") + if natsURL == "" { + natsURL = "nats://localhost:4222" + } + + // Connect to NATS + nc, err := nats.Connect(natsURL) + if err != nil { + slog.Error("Failed to connect to NATS", "error", err) + os.Exit(1) + } + defer nc.Close() + + // Create JetStream context + js, err := jetstream.New(nc) + if err != nil { + slog.Error("Failed to create JetStream context", "error", err) + os.Exit(1) + } + + // Get the project-settings KV store + kv, err := js.KeyValue(context.Background(), constants.KVStoreNameProjectSettings) + if err != nil { + slog.Error("Failed to get project-settings KV store", "error", err) + os.Exit(1) + } + + // Get current project settings + entry, err := kv.Get(context.Background(), projectUID) + if err != nil { + slog.Error("Failed to get project settings", "uid", projectUID, "error", err) + os.Exit(1) + } + + // Try to unmarshal as new format first + var newSettings models.ProjectSettings + if err := json.Unmarshal(entry.Value(), &newSettings); err == nil { + // Check if it's already in new format (has UserInfo structs) + if len(newSettings.Auditors) > 0 || len(newSettings.Writers) > 0 { + // Check if first auditor/writer has the UserInfo structure + if len(newSettings.Auditors) > 0 && newSettings.Auditors[0].Name != "" { + fmt.Printf("Project %s is already in new format\n", projectUID) + return + } + if len(newSettings.Writers) > 0 && newSettings.Writers[0].Name != "" { + fmt.Printf("Project %s is already in new format\n", projectUID) + return + } + } + } + + // Try to unmarshal as old format + var oldSettings OldProjectSettings + if err := json.Unmarshal(entry.Value(), &oldSettings); err != nil { + slog.Error("Failed to unmarshal project settings", "uid", projectUID, "error", err) + os.Exit(1) + } + + fmt.Printf("Found project settings for %s in old format\n", projectUID) + fmt.Printf("Writers: %v\n", oldSettings.Writers) + fmt.Printf("Auditors: %v\n", oldSettings.Auditors) + + // Convert to new format + newSettings = models.ProjectSettings{ + UID: oldSettings.UID, + MissionStatement: oldSettings.MissionStatement, + AnnouncementDate: oldSettings.AnnouncementDate, + Auditors: []models.UserInfo{}, + Writers: []models.UserInfo{}, + MeetingCoordinators: oldSettings.MeetingCoordinators, + CreatedAt: oldSettings.CreatedAt, + UpdatedAt: oldSettings.UpdatedAt, + } + + reader := bufio.NewReader(os.Stdin) + + // Convert auditors + for _, auditorStr := range oldSettings.Auditors { + if auditorStr == "" { + continue + } + + fmt.Printf("\nMigrating auditor: %s\n", auditorStr) + userInfo := getUserInfo(reader, auditorStr) + newSettings.Auditors = append(newSettings.Auditors, userInfo) + } + + // Convert writers + for _, writerStr := range oldSettings.Writers { + if writerStr == "" { + continue + } + + fmt.Printf("\nMigrating writer: %s\n", writerStr) + userInfo := getUserInfo(reader, writerStr) + newSettings.Writers = append(newSettings.Writers, userInfo) + } + + // Update timestamp + now := time.Now() + newSettings.UpdatedAt = &now + + // Marshal new settings + newSettingsBytes, err := json.Marshal(newSettings) + if err != nil { + slog.Error("Failed to marshal new settings", "error", err) + os.Exit(1) + } + + // Update in NATS KV store + _, err = kv.Put(context.Background(), projectUID, newSettingsBytes) + if err != nil { + slog.Error("Failed to update project settings in KV store", "error", err) + os.Exit(1) + } + + fmt.Printf("\nSuccessfully updated project settings for %s\n", projectUID) + + // Send indexer sync message + ctx := context.Background() + messageBuilder := &natsmsg.MessageBuilder{ + NatsConn: nc, + } + + // Create indexer message + indexerMessage := models.ProjectSettingsIndexerMessage{ + Action: models.ActionUpdated, + Data: newSettings, + Tags: newSettings.Tags(), + } + + if err := messageBuilder.PublishIndexerMessage(ctx, constants.IndexProjectSettingsSubject, indexerMessage); err != nil { + slog.Error("Failed to send indexer sync message", "error", err) + os.Exit(1) + } + + fmt.Printf("Sent indexer sync message for project %s\n", projectUID) +} + +func getUserInfo(reader *bufio.Reader, defaultUsername string) models.UserInfo { + fmt.Printf("Enter details for user '%s':\n", defaultUsername) + + name := getInput(reader, "Name") + username := getInputWithDefault(reader, "Username", defaultUsername) + email := getInput(reader, "Email") + avatar := getInputOptional(reader, "Avatar URL (optional)") + + return models.UserInfo{ + Name: name, + Username: username, + Email: email, + Avatar: avatar, + } +} + +func getInput(reader *bufio.Reader, prompt string) string { + for { + fmt.Printf("%s: ", prompt) + input, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + continue + } + + input = strings.TrimSpace(input) + if input != "" { + return input + } + + fmt.Println("This field is required. Please enter a value.") + } +} + +func getInputWithDefault(reader *bufio.Reader, prompt, defaultValue string) string { + fmt.Printf("%s [%s]: ", prompt, defaultValue) + input, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return defaultValue + } + + input = strings.TrimSpace(input) + if input == "" { + return defaultValue + } + + return input +} + +func getInputOptional(reader *bufio.Reader, prompt string) string { + fmt.Printf("%s: ", prompt) + input, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return "" + } + + return strings.TrimSpace(input) +} \ No newline at end of file