Complete tasks 4.1-4.2: Page management service and HTTP endpoints

- Implemented PageService with full CRUD operations
- Added GetPages, CreatePage, UpdatePage, DeletePage, ReorderPages methods
- Cascade deletion of widgets when page is deleted
- Prevention of last page deletion
- Created page HTTP endpoints (GET, POST, PUT, DELETE, reorder)
- HTMX-friendly HTML fragment responses
- Comprehensive unit tests for service and handlers
- Updated dashboard to use PageService and create default pages
This commit is contained in:
2026-02-19 00:08:05 -05:00
parent 9f07b0c6f9
commit 299ac03939
16 changed files with 1572 additions and 31 deletions

View File

@@ -0,0 +1,275 @@
package services
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/google/uuid"
"custom-start-page/internal/models"
"custom-start-page/internal/storage"
)
// PageService handles all page-related business logic
type PageService struct {
db *storage.DynamoDBClient
}
// NewPageService creates a new PageService instance
func NewPageService(db *storage.DynamoDBClient) *PageService {
return &PageService{
db: db,
}
}
// GetPages retrieves all pages for a user in display order
func (s *PageService) GetPages(ctx context.Context, userID string) ([]*models.Page, error) {
input := &dynamodb.QueryInput{
TableName: aws.String("Pages"),
KeyConditionExpression: aws.String("user_id = :user_id"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":user_id": &types.AttributeValueMemberS{Value: userID},
},
}
output, err := s.db.Query(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to query pages: %w", err)
}
var pages []*models.Page
err = attributevalue.UnmarshalListOfMaps(output.Items, &pages)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal pages: %w", err)
}
// Sort by order field
sortPagesByOrder(pages)
return pages, nil
}
// CreatePage creates a new page for a user
func (s *PageService) CreatePage(ctx context.Context, userID, name string) (*models.Page, error) {
// Get existing pages to determine the next order
existingPages, err := s.GetPages(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get existing pages: %w", err)
}
nextOrder := len(existingPages)
page := &models.Page{
ID: uuid.New().String(),
UserID: userID,
Name: name,
Order: nextOrder,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
item, err := attributevalue.MarshalMap(page)
if err != nil {
return nil, fmt.Errorf("failed to marshal page: %w", err)
}
input := &dynamodb.PutItemInput{
TableName: aws.String("Pages"),
Item: item,
}
err = s.db.PutItem(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to create page: %w", err)
}
return page, nil
}
// CreateDefaultPage creates the default "Home" page for new users
func (s *PageService) CreateDefaultPage(ctx context.Context, userID string) (*models.Page, error) {
return s.CreatePage(ctx, userID, "Home")
}
// UpdatePage updates a page's name or order
func (s *PageService) UpdatePage(ctx context.Context, userID, pageID, name string) (*models.Page, error) {
now := time.Now()
input := &dynamodb.UpdateItemInput{
TableName: aws.String("Pages"),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
"page_id": &types.AttributeValueMemberS{Value: pageID},
},
UpdateExpression: aws.String("SET #name = :name, updated_at = :updated_at"),
ExpressionAttributeNames: map[string]string{
"#name": "name",
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":name": &types.AttributeValueMemberS{Value: name},
":updated_at": &types.AttributeValueMemberS{Value: now.Format(time.RFC3339)},
},
ReturnValues: types.ReturnValueAllNew,
}
output, err := s.db.UpdateItem(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to update page: %w", err)
}
var page models.Page
err = attributevalue.UnmarshalMap(output.Attributes, &page)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal updated page: %w", err)
}
return &page, nil
}
// DeletePage deletes a page and all associated widgets
func (s *PageService) DeletePage(ctx context.Context, userID, pageID string) error {
// Check if this is the last page
pages, err := s.GetPages(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get pages: %w", err)
}
if len(pages) <= 1 {
return fmt.Errorf("cannot delete the last page")
}
// Get all widgets for this page
widgetsInput := &dynamodb.QueryInput{
TableName: aws.String("Widgets"),
KeyConditionExpression: aws.String("page_id = :page_id"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":page_id": &types.AttributeValueMemberS{Value: pageID},
},
}
widgetsOutput, err := s.db.Query(ctx, widgetsInput)
if err != nil {
return fmt.Errorf("failed to query widgets: %w", err)
}
// Delete all widgets in batches
if len(widgetsOutput.Items) > 0 {
var writeRequests []types.WriteRequest
for _, item := range widgetsOutput.Items {
writeRequests = append(writeRequests, types.WriteRequest{
DeleteRequest: &types.DeleteRequest{
Key: map[string]types.AttributeValue{
"page_id": item["page_id"],
"widget_id": item["widget_id"],
},
},
})
}
// Process in batches of 25 (DynamoDB limit)
for i := 0; i < len(writeRequests); i += 25 {
end := i + 25
if end > len(writeRequests) {
end = len(writeRequests)
}
batchInput := &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{
"Widgets": writeRequests[i:end],
},
}
err = s.db.BatchWriteItems(ctx, batchInput)
if err != nil {
return fmt.Errorf("failed to delete widgets: %w", err)
}
}
}
// Delete the page
deleteInput := &dynamodb.DeleteItemInput{
TableName: aws.String("Pages"),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
"page_id": &types.AttributeValueMemberS{Value: pageID},
},
}
err = s.db.DeleteItem(ctx, deleteInput)
if err != nil {
return fmt.Errorf("failed to delete page: %w", err)
}
return nil
}
// ReorderPages updates the display order of pages
func (s *PageService) ReorderPages(ctx context.Context, userID string, pageOrder []string) error {
// Validate that all pages exist and belong to the user
existingPages, err := s.GetPages(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get existing pages: %w", err)
}
// Create a map for quick lookup
pageMap := make(map[string]*models.Page)
for _, page := range existingPages {
pageMap[page.ID] = page
}
// Validate all page IDs in the order list
if len(pageOrder) != len(existingPages) {
return fmt.Errorf("page order list length mismatch: expected %d, got %d", len(existingPages), len(pageOrder))
}
for _, pageID := range pageOrder {
if _, exists := pageMap[pageID]; !exists {
return fmt.Errorf("page %s not found or does not belong to user", pageID)
}
}
// Update each page's order
now := time.Now()
for i, pageID := range pageOrder {
input := &dynamodb.UpdateItemInput{
TableName: aws.String("Pages"),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
"page_id": &types.AttributeValueMemberS{Value: pageID},
},
UpdateExpression: aws.String("SET #order = :order, updated_at = :updated_at"),
ExpressionAttributeNames: map[string]string{
"#order": "order",
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":order": &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", i)},
":updated_at": &types.AttributeValueMemberS{Value: now.Format(time.RFC3339)},
},
}
_, err = s.db.UpdateItem(ctx, input)
if err != nil {
return fmt.Errorf("failed to update page order for page %s: %w", pageID, err)
}
}
return nil
}
// sortPagesByOrder sorts pages by their order field (in-place)
func sortPagesByOrder(pages []*models.Page) {
// Simple bubble sort since we expect small number of pages
n := len(pages)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
if pages[j].Order > pages[j+1].Order {
pages[j], pages[j+1] = pages[j+1], pages[j]
}
}
}
}