add initial space logic/behaviour
This commit is contained in:
parent
07ebc06b32
commit
219d254b96
10 changed files with 333 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ type App struct {
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
ProfileService *service.ProfileService
|
ProfileService *service.ProfileService
|
||||||
|
SpaceService *service.SpaceService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -35,8 +36,10 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
userRepository := repository.NewUserRepository(database)
|
userRepository := repository.NewUserRepository(database)
|
||||||
profileRepository := repository.NewProfileRepository(database)
|
profileRepository := repository.NewProfileRepository(database)
|
||||||
tokenRepository := repository.NewTokenRepository(database)
|
tokenRepository := repository.NewTokenRepository(database)
|
||||||
|
spaceRepository := repository.NewSpaceRepository(database)
|
||||||
|
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
spaceService := service.NewSpaceService(spaceRepository)
|
||||||
emailService := service.NewEmailService(
|
emailService := service.NewEmailService(
|
||||||
emailClient,
|
emailClient,
|
||||||
cfg.MailerEmailFrom,
|
cfg.MailerEmailFrom,
|
||||||
|
|
@ -49,6 +52,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
userRepository,
|
userRepository,
|
||||||
profileRepository,
|
profileRepository,
|
||||||
tokenRepository,
|
tokenRepository,
|
||||||
|
spaceService,
|
||||||
cfg.JWTSecret,
|
cfg.JWTSecret,
|
||||||
cfg.JWTExpiry,
|
cfg.JWTExpiry,
|
||||||
cfg.TokenMagicLinkExpiry,
|
cfg.TokenMagicLinkExpiry,
|
||||||
|
|
@ -63,6 +67,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
EmailService: emailService,
|
EmailService: emailService,
|
||||||
ProfileService: profileService,
|
ProfileService: profileService,
|
||||||
|
SpaceService: spaceService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
internal/db/migrations/00005_create_spaces_tables.sql
Normal file
32
internal/db/migrations/00005_create_spaces_tables.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS spaces (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS space_members (
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (space_id, user_id),
|
||||||
|
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members(user_id);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP INDEX IF EXISTS idx_space_members_user;
|
||||||
|
DROP INDEX IF EXISTS idx_spaces_owner;
|
||||||
|
DROP TABLE IF EXISTS space_members;
|
||||||
|
DROP TABLE IF EXISTS spaces;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
@ -27,3 +27,7 @@ func (h *homeHandler) HomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
func (home *homeHandler) NotFoundPage(w http.ResponseWriter, r *http.Request) {
|
func (home *homeHandler) NotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||||
ui.Render(w, r, pages.NotFound())
|
ui.Render(w, r, pages.NotFound())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *homeHandler) ForbiddenPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ui.Render(w, r, pages.Forbidden())
|
||||||
|
}
|
||||||
|
|
|
||||||
50
internal/middleware/space.go
Normal file
50
internal/middleware/space.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireSpaceAccess validates that a user is a member of the space they are trying to access.
|
||||||
|
// It expects a URL parameter named "spaceID".
|
||||||
|
func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
// This should be caught by RequireAuth first, but as a safeguard.
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
if spaceID == "" {
|
||||||
|
slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceID} path parameter")
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, err := spaceService.IsMember(user.ID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to check space membership", "error", err, "user_id", user.ID, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMember {
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("HX-Redirect", "/forbidden")
|
||||||
|
w.WriteHeader(http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/forbidden", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/model/space.go
Normal file
25
internal/model/space.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleOwner Role = "owner"
|
||||||
|
RoleMember Role = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Space struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
OwnerID string `db:"owner_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpaceMember struct {
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
Role Role `db:"role"`
|
||||||
|
JoinedAt time.Time `db:"joined_at"`
|
||||||
|
}
|
||||||
108
internal/repository/space.go
Normal file
108
internal/repository/space.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSpaceNotFound = errors.New("space not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type SpaceRepository interface {
|
||||||
|
Create(space *model.Space) error
|
||||||
|
ByID(id string) (*model.Space, error)
|
||||||
|
ByUserID(userID string) ([]*model.Space, error)
|
||||||
|
AddMember(spaceID, userID string, role model.Role) error
|
||||||
|
RemoveMember(spaceID, userID string) error
|
||||||
|
IsMember(spaceID, userID string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type spaceRepository struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpaceRepository(db *sqlx.DB) SpaceRepository {
|
||||||
|
return &spaceRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) Create(space *model.Space) error {
|
||||||
|
tx, err := r.db.Beginx()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Insert Space
|
||||||
|
querySpace := `INSERT INTO spaces (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5);`
|
||||||
|
_, err = tx.Exec(querySpace, space.ID, space.Name, space.OwnerID, space.CreatedAt, space.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert Owner as Member
|
||||||
|
queryMember := `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4);`
|
||||||
|
_, err = tx.Exec(queryMember, space.ID, space.OwnerID, model.RoleOwner, space.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) ByID(id string) (*model.Space, error) {
|
||||||
|
space := &model.Space{}
|
||||||
|
query := `SELECT * FROM spaces WHERE id = $1;`
|
||||||
|
|
||||||
|
err := r.db.Get(space, query, id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrSpaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return space, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) ByUserID(userID string) ([]*model.Space, error) {
|
||||||
|
var spaces []*model.Space
|
||||||
|
// Select spaces where user is a member
|
||||||
|
query := `
|
||||||
|
SELECT s.*
|
||||||
|
FROM spaces s
|
||||||
|
JOIN space_members sm ON s.id = sm.space_id
|
||||||
|
WHERE sm.user_id = $1
|
||||||
|
ORDER BY s.created_at DESC;
|
||||||
|
`
|
||||||
|
|
||||||
|
err := r.db.Select(&spaces, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) AddMember(spaceID, userID string, role model.Role) error {
|
||||||
|
query := `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4);`
|
||||||
|
_, err := r.db.Exec(query, spaceID, userID, role, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) RemoveMember(spaceID, userID string) error {
|
||||||
|
query := `DELETE FROM space_members WHERE space_id = $1 AND user_id = $2;`
|
||||||
|
_, err := r.db.Exec(query, spaceID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
query := `SELECT count(*) FROM space_members WHERE space_id = $1 AND user_id = $2;`
|
||||||
|
err := r.db.Get(&count, query, spaceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
mux.HandleFunc("GET /{$}", home.HomePage)
|
mux.HandleFunc("GET /{$}", home.HomePage)
|
||||||
|
mux.HandleFunc("GET /forbidden", home.ForbiddenPage)
|
||||||
|
|
||||||
// Auth pages
|
// Auth pages
|
||||||
authRateLimiter := middleware.RateLimitAuth()
|
authRateLimiter := middleware.RateLimitAuth()
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type AuthService struct {
|
||||||
userRepository repository.UserRepository
|
userRepository repository.UserRepository
|
||||||
profileRepository repository.ProfileRepository
|
profileRepository repository.ProfileRepository
|
||||||
tokenRepository repository.TokenRepository
|
tokenRepository repository.TokenRepository
|
||||||
|
spaceService *SpaceService
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
jwtExpiry time.Duration
|
jwtExpiry time.Duration
|
||||||
tokenMagicLinkExpiry time.Duration
|
tokenMagicLinkExpiry time.Duration
|
||||||
|
|
@ -47,6 +48,7 @@ func NewAuthService(
|
||||||
userRepository repository.UserRepository,
|
userRepository repository.UserRepository,
|
||||||
profileRepository repository.ProfileRepository,
|
profileRepository repository.ProfileRepository,
|
||||||
tokenRepository repository.TokenRepository,
|
tokenRepository repository.TokenRepository,
|
||||||
|
spaceService *SpaceService,
|
||||||
jwtSecret string,
|
jwtSecret string,
|
||||||
jwtExpiry time.Duration,
|
jwtExpiry time.Duration,
|
||||||
tokenMagicLinkExpiry time.Duration,
|
tokenMagicLinkExpiry time.Duration,
|
||||||
|
|
@ -57,6 +59,7 @@ func NewAuthService(
|
||||||
userRepository: userRepository,
|
userRepository: userRepository,
|
||||||
profileRepository: profileRepository,
|
profileRepository: profileRepository,
|
||||||
tokenRepository: tokenRepository,
|
tokenRepository: tokenRepository,
|
||||||
|
spaceService: spaceService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
jwtExpiry: jwtExpiry,
|
jwtExpiry: jwtExpiry,
|
||||||
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
|
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
|
||||||
|
|
@ -214,6 +217,12 @@ func (s *AuthService) SendMagicLink(email string) error {
|
||||||
return fmt.Errorf("failed to create profile: %w", err)
|
return fmt.Errorf("failed to create profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = s.spaceService.EnsurePersonalSpace(user)
|
||||||
|
if err != nil {
|
||||||
|
// Log the error but don't fail the whole auth flow
|
||||||
|
slog.Error("failed to create personal space for new user", "error", err, "user_id", user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
|
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
|
||||||
} else {
|
} else {
|
||||||
// user look up unexpected error
|
// user look up unexpected error
|
||||||
|
|
|
||||||
81
internal/service/space.go
Normal file
81
internal/service/space.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PersonalSpaceName = "Personal Space"
|
||||||
|
|
||||||
|
type SpaceService struct {
|
||||||
|
spaceRepo repository.SpaceRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService {
|
||||||
|
return &SpaceService{
|
||||||
|
spaceRepo: spaceRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSpace creates a new space and sets the owner.
|
||||||
|
func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("space name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
space := &model.Space{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: name,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.spaceRepo.Create(space)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create space: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePersonalSpace creates a "Personal Space" for a user if one doesn't exist.
|
||||||
|
func (s *SpaceService) EnsurePersonalSpace(user *model.User) (*model.Space, error) {
|
||||||
|
spaces, err := s.spaceRepo.ByUserID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user spaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a personal space already exists.
|
||||||
|
// We identify it by the user being the owner and the name being the default.
|
||||||
|
for _, space := range spaces {
|
||||||
|
if space.OwnerID == user.ID && space.Name == PersonalSpaceName {
|
||||||
|
return space, nil // Personal space already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no personal space, create one.
|
||||||
|
return s.CreateSpace(PersonalSpaceName, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpacesForUser returns all spaces a user is a member of.
|
||||||
|
func (s *SpaceService) GetSpacesForUser(userID string) ([]*model.Space, error) {
|
||||||
|
spaces, err := s.spaceRepo.ByUserID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get spaces for user: %w", err)
|
||||||
|
}
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMember checks if a user is a member of a given space.
|
||||||
|
func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
|
||||||
|
isMember, err := s.spaceRepo.IsMember(spaceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check membership: %w", err)
|
||||||
|
}
|
||||||
|
return isMember, nil
|
||||||
|
}
|
||||||
18
internal/ui/pages/public_forbidden.templ
Normal file
18
internal/ui/pages/public_forbidden.templ
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
|
|
||||||
|
templ Forbidden() {
|
||||||
|
@layouts.Base() {
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-8xl font-bold text-muted-foreground/30 mb-4">403</h1>
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Access Denied</h2>
|
||||||
|
<p class="text-muted-foreground mb-8">You do not have permission to access this page.</p>
|
||||||
|
<a href="/app/dashboard" class="text-primary hover:underline">
|
||||||
|
← Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue